Skip to main content

oxidize_pdf/annotations/
text.rs

1//! Text annotation (sticky note) implementation
2
3use crate::annotations::Annotation;
4use crate::geometry::{Point, Rectangle};
5use crate::objects::Object;
6
7/// Icon types for text annotations
8#[derive(Debug, Clone, Copy, Default)]
9pub enum Icon {
10    /// Comment icon
11    Comment,
12    /// Key icon
13    Key,
14    /// Note icon (default)
15    #[default]
16    Note,
17    /// Help icon
18    Help,
19    /// New paragraph icon
20    NewParagraph,
21    /// Paragraph icon
22    Paragraph,
23    /// Insert icon
24    Insert,
25}
26
27impl Icon {
28    /// Get PDF icon name
29    pub fn pdf_name(&self) -> &'static str {
30        match self {
31            Icon::Comment => "Comment",
32            Icon::Key => "Key",
33            Icon::Note => "Note",
34            Icon::Help => "Help",
35            Icon::NewParagraph => "NewParagraph",
36            Icon::Paragraph => "Paragraph",
37            Icon::Insert => "Insert",
38        }
39    }
40}
41
42/// Text annotation (sticky note)
43#[derive(Debug, Clone)]
44pub struct TextAnnotation {
45    /// Base annotation
46    pub annotation: Annotation,
47    /// Icon type
48    pub icon: Icon,
49    /// Whether annotation should initially be open
50    pub open: bool,
51    /// State model (Review, Marked)
52    pub state_model: Option<String>,
53    /// State (Accepted, Rejected, Cancelled, Completed, None)
54    pub state: Option<String>,
55}
56
57impl TextAnnotation {
58    /// Create a new text annotation at a point
59    pub fn new(position: Point) -> Self {
60        // Text annotations are typically 20x20 points
61        let rect = Rectangle::new(position, Point::new(position.x + 20.0, position.y + 20.0));
62
63        let annotation = Annotation::new(crate::annotations::AnnotationType::Text, rect);
64
65        Self {
66            annotation,
67            icon: Icon::default(),
68            open: false,
69            state_model: None,
70            state: None,
71        }
72    }
73
74    /// Set the icon
75    pub fn with_icon(mut self, icon: Icon) -> Self {
76        self.icon = icon;
77        self
78    }
79
80    /// Set whether annotation is initially open
81    pub fn open(mut self) -> Self {
82        self.open = true;
83        self
84    }
85
86    /// Set contents
87    pub fn with_contents(mut self, contents: impl Into<String>) -> Self {
88        self.annotation.contents = Some(contents.into());
89        self
90    }
91
92    /// Set state
93    pub fn with_state(mut self, state_model: impl Into<String>, state: impl Into<String>) -> Self {
94        self.state_model = Some(state_model.into());
95        self.state = Some(state.into());
96        self
97    }
98
99    /// Convert to annotation with properties
100    pub fn to_annotation(self) -> Annotation {
101        let mut annotation = self.annotation;
102
103        // Set icon
104        annotation
105            .properties
106            .set("Name", Object::Name(self.icon.pdf_name().to_string()));
107
108        // Set open state
109        annotation
110            .properties
111            .set("Open", Object::Boolean(self.open));
112
113        // Set state if present
114        if let Some(state_model) = self.state_model {
115            annotation
116                .properties
117                .set("StateModel", Object::String(state_model));
118        }
119
120        if let Some(state) = self.state {
121            annotation.properties.set("State", Object::String(state));
122        }
123
124        annotation
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::geometry::Point;
132
133    #[test]
134    fn test_icon_names() {
135        assert_eq!(Icon::Comment.pdf_name(), "Comment");
136        assert_eq!(Icon::Note.pdf_name(), "Note");
137        assert_eq!(Icon::Help.pdf_name(), "Help");
138    }
139
140    #[test]
141    fn test_text_annotation_creation() {
142        let position = Point::new(100.0, 700.0);
143        let text_annot = TextAnnotation::new(position)
144            .with_contents("This is a note")
145            .with_icon(Icon::Comment)
146            .open();
147
148        assert_eq!(text_annot.icon.pdf_name(), "Comment");
149        assert!(text_annot.open);
150        assert_eq!(
151            text_annot.annotation.contents,
152            Some("This is a note".to_string())
153        );
154    }
155
156    #[test]
157    fn test_text_annotation_to_annotation() {
158        let position = Point::new(50.0, 650.0);
159        let text_annot = TextAnnotation::new(position)
160            .with_contents("Review this section")
161            .with_state("Review", "Accepted");
162
163        let annotation = text_annot.to_annotation();
164        assert!(annotation.properties.get("Name").is_some());
165        assert_eq!(
166            annotation.properties.get("Open"),
167            Some(&Object::Boolean(false))
168        );
169        assert_eq!(
170            annotation.properties.get("StateModel"),
171            Some(&Object::String("Review".to_string()))
172        );
173        assert_eq!(
174            annotation.properties.get("State"),
175            Some(&Object::String("Accepted".to_string()))
176        );
177    }
178
179    #[test]
180    fn test_text_annotation_rect() {
181        let position = Point::new(200.0, 500.0);
182        let text_annot = TextAnnotation::new(position);
183
184        let rect = text_annot.annotation.rect;
185        assert_eq!(rect.lower_left.x, 200.0);
186        assert_eq!(rect.lower_left.y, 500.0);
187        assert_eq!(rect.upper_right.x, 220.0); // 20 points wide
188        assert_eq!(rect.upper_right.y, 520.0); // 20 points tall
189    }
190
191    #[test]
192    fn test_all_icon_types() {
193        let icons = [
194            Icon::Comment,
195            Icon::Key,
196            Icon::Note,
197            Icon::Help,
198            Icon::NewParagraph,
199            Icon::Paragraph,
200            Icon::Insert,
201        ];
202
203        let expected_names = [
204            "Comment",
205            "Key",
206            "Note",
207            "Help",
208            "NewParagraph",
209            "Paragraph",
210            "Insert",
211        ];
212
213        for (icon, expected) in icons.iter().zip(expected_names.iter()) {
214            assert_eq!(icon.pdf_name(), *expected);
215        }
216    }
217
218    #[test]
219    fn test_icon_default() {
220        let default_icon = Icon::default();
221        assert!(matches!(default_icon, Icon::Note));
222        assert_eq!(default_icon.pdf_name(), "Note");
223    }
224
225    #[test]
226    fn test_icon_debug_clone_copy() {
227        let icon = Icon::Help;
228
229        // Test Debug
230        let debug_str = format!("{icon:?}");
231        assert_eq!(debug_str, "Help");
232
233        // Test Clone
234        let cloned = icon;
235        assert!(matches!(cloned, Icon::Help));
236
237        // Test Copy
238        let copied: Icon = icon; // Copy semantics
239        assert!(matches!(copied, Icon::Help));
240        assert!(matches!(icon, Icon::Help)); // Original still usable
241    }
242
243    #[test]
244    fn test_text_annotation_default_values() {
245        let position = Point::new(0.0, 0.0);
246        let text_annot = TextAnnotation::new(position);
247
248        assert!(matches!(text_annot.icon, Icon::Note));
249        assert!(!text_annot.open);
250        assert!(text_annot.state_model.is_none());
251        assert!(text_annot.state.is_none());
252        assert!(text_annot.annotation.contents.is_none());
253    }
254
255    #[test]
256    fn test_text_annotation_builder_chain() {
257        let position = Point::new(100.0, 200.0);
258        let text_annot = TextAnnotation::new(position)
259            .with_icon(Icon::Paragraph)
260            .open()
261            .with_contents("Important paragraph")
262            .with_state("Marked", "Completed");
263
264        assert!(matches!(text_annot.icon, Icon::Paragraph));
265        assert!(text_annot.open);
266        assert_eq!(
267            text_annot.annotation.contents,
268            Some("Important paragraph".to_string())
269        );
270        assert_eq!(text_annot.state_model, Some("Marked".to_string()));
271        assert_eq!(text_annot.state, Some("Completed".to_string()));
272    }
273
274    #[test]
275    fn test_text_annotation_with_empty_contents() {
276        let position = Point::new(50.0, 50.0);
277        let text_annot = TextAnnotation::new(position).with_contents("");
278
279        assert_eq!(text_annot.annotation.contents, Some("".to_string()));
280    }
281
282    #[test]
283    fn test_text_annotation_with_long_contents() {
284        let position = Point::new(0.0, 0.0);
285        let long_text = "a".repeat(1000);
286        let text_annot = TextAnnotation::new(position).with_contents(long_text.clone());
287
288        assert_eq!(text_annot.annotation.contents, Some(long_text));
289    }
290
291    #[test]
292    fn test_text_annotation_state_variations() {
293        let position = Point::new(100.0, 100.0);
294
295        // Test Review state model
296        let review_annot = TextAnnotation::new(position).with_state("Review", "Accepted");
297        assert_eq!(review_annot.state_model, Some("Review".to_string()));
298        assert_eq!(review_annot.state, Some("Accepted".to_string()));
299
300        // Test Marked state model
301        let marked_annot = TextAnnotation::new(position).with_state("Marked", "Completed");
302        assert_eq!(marked_annot.state_model, Some("Marked".to_string()));
303        assert_eq!(marked_annot.state, Some("Completed".to_string()));
304    }
305
306    #[test]
307    fn test_text_annotation_different_positions() {
308        let positions = vec![
309            Point::new(0.0, 0.0),
310            Point::new(-100.0, -100.0),
311            Point::new(1000.0, 2000.0),
312            Point::new(0.5, 0.5),
313        ];
314
315        for pos in positions {
316            let text_annot = TextAnnotation::new(pos);
317            assert_eq!(text_annot.annotation.rect.lower_left.x, pos.x);
318            assert_eq!(text_annot.annotation.rect.lower_left.y, pos.y);
319            assert_eq!(text_annot.annotation.rect.upper_right.x, pos.x + 20.0);
320            assert_eq!(text_annot.annotation.rect.upper_right.y, pos.y + 20.0);
321        }
322    }
323
324    #[test]
325    fn test_to_annotation_without_state() {
326        let position = Point::new(150.0, 350.0);
327        let text_annot = TextAnnotation::new(position)
328            .with_icon(Icon::Key)
329            .with_contents("Key information");
330
331        let annotation = text_annot.to_annotation();
332
333        assert_eq!(
334            annotation.properties.get("Name"),
335            Some(&Object::Name("Key".to_string()))
336        );
337        assert_eq!(
338            annotation.properties.get("Open"),
339            Some(&Object::Boolean(false))
340        );
341        assert!(annotation.properties.get("StateModel").is_none());
342        assert!(annotation.properties.get("State").is_none());
343    }
344
345    #[test]
346    fn test_to_annotation_open_state() {
347        let position = Point::new(75.0, 125.0);
348        let text_annot = TextAnnotation::new(position).open();
349
350        let annotation = text_annot.to_annotation();
351
352        assert_eq!(
353            annotation.properties.get("Open"),
354            Some(&Object::Boolean(true))
355        );
356    }
357
358    #[test]
359    fn test_text_annotation_clone() {
360        let position = Point::new(25.0, 75.0);
361        let text_annot = TextAnnotation::new(position)
362            .with_icon(Icon::Insert)
363            .open()
364            .with_contents("Insert here")
365            .with_state("Review", "Rejected");
366
367        let cloned = text_annot.clone();
368
369        assert!(matches!(cloned.icon, Icon::Insert));
370        assert_eq!(cloned.open, text_annot.open);
371        assert_eq!(cloned.annotation.contents, text_annot.annotation.contents);
372        assert_eq!(cloned.state_model, text_annot.state_model);
373        assert_eq!(cloned.state, text_annot.state);
374    }
375
376    #[test]
377    fn test_text_annotation_debug() {
378        let position = Point::new(300.0, 400.0);
379        let text_annot = TextAnnotation::new(position).with_icon(Icon::NewParagraph);
380
381        let debug_str = format!("{text_annot:?}");
382        assert!(debug_str.contains("TextAnnotation"));
383        assert!(debug_str.contains("NewParagraph"));
384    }
385
386    #[test]
387    fn test_annotation_type_consistency() {
388        let position = Point::new(10.0, 20.0);
389        let text_annot = TextAnnotation::new(position);
390
391        // Verify the annotation type is set correctly
392        assert_eq!(
393            text_annot.annotation.annotation_type,
394            crate::annotations::AnnotationType::Text
395        );
396    }
397
398    #[test]
399    fn test_with_contents_string_types() {
400        let position = Point::new(0.0, 0.0);
401
402        // Test with &str
403        let annot1 = TextAnnotation::new(position).with_contents("string slice");
404        assert_eq!(annot1.annotation.contents, Some("string slice".to_string()));
405
406        // Test with String
407        let annot2 = TextAnnotation::new(position).with_contents(String::from("owned string"));
408        assert_eq!(annot2.annotation.contents, Some("owned string".to_string()));
409
410        // Test with &String
411        let content = String::from("ref string");
412        let annot3 = TextAnnotation::new(position).with_contents(&content);
413        assert_eq!(annot3.annotation.contents, Some("ref string".to_string()));
414    }
415
416    #[test]
417    fn test_with_state_string_types() {
418        let position = Point::new(0.0, 0.0);
419
420        // Test with &str
421        let annot1 = TextAnnotation::new(position).with_state("Review", "Accepted");
422        assert_eq!(annot1.state_model, Some("Review".to_string()));
423        assert_eq!(annot1.state, Some("Accepted".to_string()));
424
425        // Test with String
426        let annot2 =
427            TextAnnotation::new(position).with_state(String::from("Marked"), String::from("None"));
428        assert_eq!(annot2.state_model, Some("Marked".to_string()));
429        assert_eq!(annot2.state, Some("None".to_string()));
430    }
431
432    #[test]
433    fn test_special_characters_in_contents() {
434        let position = Point::new(0.0, 0.0);
435        let special_content = "Line 1\nLine 2\tTabbed\r\nSpecial chars: ()[]{}\\";
436
437        let text_annot = TextAnnotation::new(position).with_contents(special_content);
438
439        assert_eq!(
440            text_annot.annotation.contents,
441            Some(special_content.to_string())
442        );
443    }
444
445    #[test]
446    fn test_unicode_in_contents() {
447        let position = Point::new(0.0, 0.0);
448        let unicode_content = "Unicode: 你好世界 🌍 Ñoño";
449
450        let text_annot = TextAnnotation::new(position).with_contents(unicode_content);
451
452        assert_eq!(
453            text_annot.annotation.contents,
454            Some(unicode_content.to_string())
455        );
456    }
457
458    #[test]
459    fn test_all_state_combinations() {
460        let position = Point::new(0.0, 0.0);
461
462        let state_combinations = vec![
463            (
464                "Review",
465                vec!["Accepted", "Rejected", "Cancelled", "Completed", "None"],
466            ),
467            ("Marked", vec!["Marked", "Unmarked"]),
468        ];
469
470        for (model, states) in state_combinations {
471            for state in states {
472                let text_annot = TextAnnotation::new(position).with_state(model, state);
473
474                let annotation = text_annot.to_annotation();
475                assert_eq!(
476                    annotation.properties.get("StateModel"),
477                    Some(&Object::String(model.to_string()))
478                );
479                assert_eq!(
480                    annotation.properties.get("State"),
481                    Some(&Object::String(state.to_string()))
482                );
483            }
484        }
485    }
486
487    #[test]
488    fn test_extreme_positions() {
489        let extreme_positions = vec![
490            Point::new(f64::MIN, f64::MIN),
491            Point::new(f64::MAX, f64::MAX),
492            Point::new(0.0, f64::MAX),
493            Point::new(f64::MAX, 0.0),
494            Point::new(-1e10, -1e10),
495            Point::new(1e10, 1e10),
496        ];
497
498        for pos in extreme_positions {
499            let text_annot = TextAnnotation::new(pos);
500            assert_eq!(text_annot.annotation.rect.lower_left.x, pos.x);
501            assert_eq!(text_annot.annotation.rect.lower_left.y, pos.y);
502            // Check that the 20-point offset doesn't cause issues
503            assert_eq!(text_annot.annotation.rect.upper_right.x, pos.x + 20.0);
504            assert_eq!(text_annot.annotation.rect.upper_right.y, pos.y + 20.0);
505        }
506    }
507
508    #[test]
509    fn test_pdf_properties_structure() {
510        let position = Point::new(100.0, 100.0);
511        let text_annot = TextAnnotation::new(position)
512            .with_icon(Icon::Comment)
513            .open()
514            .with_contents("Test comment")
515            .with_state("Review", "Accepted");
516
517        let annotation = text_annot.to_annotation();
518
519        // Verify all expected properties are present
520        assert!(annotation.properties.get("Name").is_some());
521        assert!(annotation.properties.get("Open").is_some());
522        assert!(annotation.properties.get("StateModel").is_some());
523        assert!(annotation.properties.get("State").is_some());
524
525        // Verify property types
526        assert!(matches!(
527            annotation.properties.get("Name"),
528            Some(Object::Name(_))
529        ));
530        assert!(matches!(
531            annotation.properties.get("Open"),
532            Some(Object::Boolean(_))
533        ));
534        assert!(matches!(
535            annotation.properties.get("StateModel"),
536            Some(Object::String(_))
537        ));
538        assert!(matches!(
539            annotation.properties.get("State"),
540            Some(Object::String(_))
541        ));
542    }
543
544    #[test]
545    fn test_repeated_builder_calls() {
546        let position = Point::new(50.0, 50.0);
547
548        // Test that multiple calls to the same builder method use the last value
549        let text_annot = TextAnnotation::new(position)
550            .with_icon(Icon::Note)
551            .with_icon(Icon::Help) // Should override to Help
552            .with_contents("First")
553            .with_contents("Second") // Should override to Second
554            .with_state("Review", "Accepted")
555            .with_state("Marked", "Completed"); // Should override to Marked/Completed
556
557        assert!(matches!(text_annot.icon, Icon::Help));
558        assert_eq!(text_annot.annotation.contents, Some("Second".to_string()));
559        assert_eq!(text_annot.state_model, Some("Marked".to_string()));
560        assert_eq!(text_annot.state, Some("Completed".to_string()));
561    }
562}