Skip to main content

oxidize_pdf/annotations/
markup.rs

1//! Markup annotation implementation (highlight, underline, strikeout, squiggly)
2
3use crate::annotations::Annotation;
4use crate::geometry::Rectangle;
5use crate::graphics::Color;
6use crate::objects::Object;
7
8/// Markup annotation types
9#[derive(Debug, Clone, Copy)]
10pub enum MarkupType {
11    /// Highlight text
12    Highlight,
13    /// Underline text
14    Underline,
15    /// Strikeout text
16    StrikeOut,
17    /// Squiggly underline
18    Squiggly,
19}
20
21impl MarkupType {
22    /// Get annotation type
23    pub fn annotation_type(&self) -> crate::annotations::AnnotationType {
24        match self {
25            MarkupType::Highlight => crate::annotations::AnnotationType::Highlight,
26            MarkupType::Underline => crate::annotations::AnnotationType::Underline,
27            MarkupType::StrikeOut => crate::annotations::AnnotationType::StrikeOut,
28            MarkupType::Squiggly => crate::annotations::AnnotationType::Squiggly,
29        }
30    }
31}
32
33/// Quad points defining the region to be marked up
34#[derive(Debug, Clone)]
35pub struct QuadPoints {
36    /// Points defining quadrilaterals (8 numbers per quad)
37    pub points: Vec<f64>,
38}
39
40impl QuadPoints {
41    /// Create quad points from a rectangle
42    pub fn from_rect(rect: &Rectangle) -> Self {
43        // Order: x1,y1, x2,y2, x3,y3, x4,y4 (counterclockwise from lower-left)
44        let points = vec![
45            rect.lower_left.x,
46            rect.lower_left.y, // Lower-left
47            rect.upper_right.x,
48            rect.lower_left.y, // Lower-right
49            rect.upper_right.x,
50            rect.upper_right.y, // Upper-right
51            rect.lower_left.x,
52            rect.upper_right.y, // Upper-left
53        ];
54
55        Self { points }
56    }
57
58    /// Create quad points from multiple rectangles
59    pub fn from_rects(rects: &[Rectangle]) -> Self {
60        let mut points = Vec::new();
61
62        for rect in rects {
63            points.extend_from_slice(&[
64                rect.lower_left.x,
65                rect.lower_left.y,
66                rect.upper_right.x,
67                rect.lower_left.y,
68                rect.upper_right.x,
69                rect.upper_right.y,
70                rect.lower_left.x,
71                rect.upper_right.y,
72            ]);
73        }
74
75        Self { points }
76    }
77
78    /// Convert to PDF array
79    pub fn to_array(&self) -> Object {
80        let objects: Vec<Object> = self.points.iter().map(|&p| Object::Real(p)).collect();
81        Object::Array(objects)
82    }
83}
84
85/// Markup annotation
86#[derive(Debug, Clone)]
87pub struct MarkupAnnotation {
88    /// Base annotation
89    pub annotation: Annotation,
90    /// Markup type
91    pub markup_type: MarkupType,
92    /// Quad points
93    pub quad_points: QuadPoints,
94    /// Author
95    pub author: Option<String>,
96    /// Subject
97    pub subject: Option<String>,
98}
99
100impl MarkupAnnotation {
101    /// Create a new markup annotation
102    pub fn new(markup_type: MarkupType, rect: Rectangle, quad_points: QuadPoints) -> Self {
103        let annotation_type = markup_type.annotation_type();
104        let mut annotation = Annotation::new(annotation_type, rect);
105
106        // Set default colors based on type
107        annotation.color = Some(match markup_type {
108            MarkupType::Highlight => Color::Rgb(1.0, 1.0, 0.0), // Yellow
109            MarkupType::Underline => Color::Rgb(0.0, 0.0, 1.0), // Blue
110            MarkupType::StrikeOut => Color::Rgb(1.0, 0.0, 0.0), // Red
111            MarkupType::Squiggly => Color::Rgb(0.0, 1.0, 0.0),  // Green
112        });
113
114        Self {
115            annotation,
116            markup_type,
117            quad_points,
118            author: None,
119            subject: None,
120        }
121    }
122
123    /// Create a highlight annotation
124    pub fn highlight(rect: Rectangle) -> Self {
125        let quad_points = QuadPoints::from_rect(&rect);
126        Self::new(MarkupType::Highlight, rect, quad_points)
127    }
128
129    /// Create an underline annotation
130    pub fn underline(rect: Rectangle) -> Self {
131        let quad_points = QuadPoints::from_rect(&rect);
132        Self::new(MarkupType::Underline, rect, quad_points)
133    }
134
135    /// Create a strikeout annotation
136    pub fn strikeout(rect: Rectangle) -> Self {
137        let quad_points = QuadPoints::from_rect(&rect);
138        Self::new(MarkupType::StrikeOut, rect, quad_points)
139    }
140
141    /// Create a squiggly annotation
142    pub fn squiggly(rect: Rectangle) -> Self {
143        let quad_points = QuadPoints::from_rect(&rect);
144        Self::new(MarkupType::Squiggly, rect, quad_points)
145    }
146
147    /// Set author
148    pub fn with_author(mut self, author: impl Into<String>) -> Self {
149        self.author = Some(author.into());
150        self
151    }
152
153    /// Set subject
154    pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
155        self.subject = Some(subject.into());
156        self
157    }
158
159    /// Set contents
160    pub fn with_contents(mut self, contents: impl Into<String>) -> Self {
161        self.annotation.contents = Some(contents.into());
162        self
163    }
164
165    /// Set color
166    pub fn with_color(mut self, color: Color) -> Self {
167        self.annotation.color = Some(color);
168        self
169    }
170
171    /// Convert to annotation with properties
172    pub fn to_annotation(self) -> Annotation {
173        let mut annotation = self.annotation;
174
175        // Set quad points
176        annotation
177            .properties
178            .set("QuadPoints", self.quad_points.to_array());
179
180        // Set author if present
181        if let Some(author) = self.author {
182            annotation.properties.set("T", Object::String(author));
183        }
184
185        // Set subject if present
186        if let Some(subject) = self.subject {
187            annotation.properties.set("Subj", Object::String(subject));
188        }
189
190        annotation
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::geometry::Point;
198
199    #[test]
200    fn test_markup_type() {
201        assert!(matches!(
202            MarkupType::Highlight.annotation_type(),
203            crate::annotations::AnnotationType::Highlight
204        ));
205        assert!(matches!(
206            MarkupType::Underline.annotation_type(),
207            crate::annotations::AnnotationType::Underline
208        ));
209    }
210
211    #[test]
212    fn test_quad_points_from_rect() {
213        let rect = Rectangle::new(Point::new(100.0, 100.0), Point::new(200.0, 120.0));
214
215        let quad = QuadPoints::from_rect(&rect);
216        assert_eq!(quad.points.len(), 8);
217        assert_eq!(quad.points[0], 100.0); // x1
218        assert_eq!(quad.points[1], 100.0); // y1
219        assert_eq!(quad.points[2], 200.0); // x2
220        assert_eq!(quad.points[3], 100.0); // y2
221    }
222
223    #[test]
224    fn test_highlight_annotation() {
225        let rect = Rectangle::new(Point::new(50.0, 500.0), Point::new(250.0, 515.0));
226
227        let highlight = MarkupAnnotation::highlight(rect)
228            .with_author("John Doe")
229            .with_contents("Important text");
230
231        assert!(matches!(highlight.markup_type, MarkupType::Highlight));
232        assert_eq!(highlight.author, Some("John Doe".to_string()));
233        assert_eq!(
234            highlight.annotation.contents,
235            Some("Important text".to_string())
236        );
237    }
238
239    #[test]
240    fn test_markup_default_colors() {
241        let rect = Rectangle::new(Point::new(0.0, 0.0), Point::new(100.0, 20.0));
242
243        let highlight = MarkupAnnotation::highlight(rect);
244        assert!(matches!(
245            highlight.annotation.color,
246            Some(Color::Rgb(1.0, 1.0, 0.0))
247        ));
248
249        let underline = MarkupAnnotation::underline(rect);
250        assert!(matches!(
251            underline.annotation.color,
252            Some(Color::Rgb(0.0, 0.0, 1.0))
253        ));
254
255        let strikeout = MarkupAnnotation::strikeout(rect);
256        assert!(matches!(
257            strikeout.annotation.color,
258            Some(Color::Rgb(1.0, 0.0, 0.0))
259        ));
260
261        let squiggly = MarkupAnnotation::squiggly(rect);
262        assert!(matches!(
263            squiggly.annotation.color,
264            Some(Color::Rgb(0.0, 1.0, 0.0))
265        ));
266    }
267
268    #[test]
269    fn test_quad_points_from_multiple_rects() {
270        let rects = vec![
271            Rectangle::new(Point::new(100.0, 100.0), Point::new(200.0, 120.0)),
272            Rectangle::new(Point::new(100.0, 130.0), Point::new(180.0, 150.0)),
273            Rectangle::new(Point::new(100.0, 160.0), Point::new(220.0, 180.0)),
274        ];
275
276        let quad_points = QuadPoints::from_rects(&rects);
277
278        // 3 rectangles * 8 coordinates each = 24 total
279        assert_eq!(quad_points.points.len(), 24);
280
281        // Verify first rectangle coordinates
282        assert_eq!(quad_points.points[0], 100.0); // x1
283        assert_eq!(quad_points.points[1], 100.0); // y1
284        assert_eq!(quad_points.points[2], 200.0); // x2
285        assert_eq!(quad_points.points[3], 100.0); // y2
286        assert_eq!(quad_points.points[4], 200.0); // x3
287        assert_eq!(quad_points.points[5], 120.0); // y3
288        assert_eq!(quad_points.points[6], 100.0); // x4
289        assert_eq!(quad_points.points[7], 120.0); // y4
290
291        // Verify second rectangle starts at index 8
292        assert_eq!(quad_points.points[8], 100.0);
293        assert_eq!(quad_points.points[9], 130.0);
294    }
295
296    #[test]
297    fn test_quad_points_to_array() {
298        let points = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0];
299        let quad_points = QuadPoints {
300            points: points.clone(),
301        };
302
303        if let Object::Array(array) = quad_points.to_array() {
304            assert_eq!(array.len(), 8);
305            for (i, point) in points.iter().enumerate() {
306                assert_eq!(array[i], Object::Real(*point));
307            }
308        } else {
309            panic!("Expected array");
310        }
311    }
312
313    #[test]
314    fn test_markup_type_annotation_types() {
315        assert!(matches!(
316            MarkupType::Highlight.annotation_type(),
317            crate::annotations::AnnotationType::Highlight
318        ));
319        assert!(matches!(
320            MarkupType::Underline.annotation_type(),
321            crate::annotations::AnnotationType::Underline
322        ));
323        assert!(matches!(
324            MarkupType::StrikeOut.annotation_type(),
325            crate::annotations::AnnotationType::StrikeOut
326        ));
327        assert!(matches!(
328            MarkupType::Squiggly.annotation_type(),
329            crate::annotations::AnnotationType::Squiggly
330        ));
331    }
332
333    #[test]
334    fn test_markup_annotation_complete_workflow() {
335        let rect = Rectangle::new(Point::new(100.0, 400.0), Point::new(500.0, 420.0));
336        let quad_points = QuadPoints::from_rect(&rect);
337
338        let markup = MarkupAnnotation::new(MarkupType::Highlight, rect, quad_points)
339            .with_author("Jane Smith")
340            .with_subject("Important passage")
341            .with_contents("This section explains the key concept")
342            .with_color(Color::Rgb(1.0, 0.8, 0.0));
343
344        // Verify all properties are set
345        assert_eq!(markup.author, Some("Jane Smith".to_string()));
346        assert_eq!(markup.subject, Some("Important passage".to_string()));
347        assert_eq!(
348            markup.annotation.contents,
349            Some("This section explains the key concept".to_string())
350        );
351        assert!(matches!(
352            markup.annotation.color,
353            Some(Color::Rgb(1.0, 0.8, 0.0))
354        ));
355
356        // Convert to annotation and verify dictionary
357        let annotation = markup.to_annotation();
358        let dict = annotation.to_dict();
359
360        assert_eq!(dict.get("Type"), Some(&Object::Name("Annot".to_string())));
361        assert_eq!(
362            dict.get("Subtype"),
363            Some(&Object::Name("Highlight".to_string()))
364        );
365        assert!(dict.get("QuadPoints").is_some());
366        assert_eq!(
367            dict.get("T"),
368            Some(&Object::String("Jane Smith".to_string()))
369        );
370        assert_eq!(
371            dict.get("Subj"),
372            Some(&Object::String("Important passage".to_string()))
373        );
374    }
375
376    #[test]
377    fn test_markup_with_empty_metadata() {
378        let rect = Rectangle::new(Point::new(0.0, 0.0), Point::new(100.0, 20.0));
379
380        let markup = MarkupAnnotation::underline(rect)
381            .with_author("")
382            .with_subject("")
383            .with_contents("");
384
385        assert_eq!(markup.author, Some("".to_string()));
386        assert_eq!(markup.subject, Some("".to_string()));
387        assert_eq!(markup.annotation.contents, Some("".to_string()));
388
389        let annotation = markup.to_annotation();
390        let dict = annotation.to_dict();
391
392        // Empty strings should still be included
393        assert_eq!(dict.get("T"), Some(&Object::String("".to_string())));
394        assert_eq!(dict.get("Subj"), Some(&Object::String("".to_string())));
395        assert_eq!(dict.get("Contents"), Some(&Object::String("".to_string())));
396    }
397
398    #[test]
399    fn test_markup_with_unicode_metadata() {
400        let rect = Rectangle::new(Point::new(50.0, 50.0), Point::new(150.0, 70.0));
401
402        let markup = MarkupAnnotation::strikeout(rect)
403            .with_author("作者名")
404            .with_subject("Тема аннотации")
405            .with_contents("محتوى التعليق التوضيحي");
406
407        assert_eq!(markup.author, Some("作者名".to_string()));
408        assert_eq!(markup.subject, Some("Тема аннотации".to_string()));
409        assert_eq!(
410            markup.annotation.contents,
411            Some("محتوى التعليق التوضيحي".to_string())
412        );
413    }
414
415    #[test]
416    fn test_markup_convenience_methods() {
417        let rect = Rectangle::new(Point::new(100.0, 100.0), Point::new(300.0, 120.0));
418
419        // Test highlight convenience method
420        let highlight = MarkupAnnotation::highlight(rect);
421        assert!(matches!(highlight.markup_type, MarkupType::Highlight));
422        assert_eq!(
423            highlight.annotation.annotation_type,
424            crate::annotations::AnnotationType::Highlight
425        );
426
427        // Test underline convenience method
428        let underline = MarkupAnnotation::underline(rect);
429        assert!(matches!(underline.markup_type, MarkupType::Underline));
430        assert_eq!(
431            underline.annotation.annotation_type,
432            crate::annotations::AnnotationType::Underline
433        );
434
435        // Test strikeout convenience method
436        let strikeout = MarkupAnnotation::strikeout(rect);
437        assert!(matches!(strikeout.markup_type, MarkupType::StrikeOut));
438        assert_eq!(
439            strikeout.annotation.annotation_type,
440            crate::annotations::AnnotationType::StrikeOut
441        );
442
443        // Test squiggly convenience method
444        let squiggly = MarkupAnnotation::squiggly(rect);
445        assert!(matches!(squiggly.markup_type, MarkupType::Squiggly));
446        assert_eq!(
447            squiggly.annotation.annotation_type,
448            crate::annotations::AnnotationType::Squiggly
449        );
450    }
451
452    #[test]
453    fn test_quad_points_edge_cases() {
454        // Test with empty rectangles
455        let empty_rects: Vec<Rectangle> = vec![];
456        let empty_quad = QuadPoints::from_rects(&empty_rects);
457        assert!(empty_quad.points.is_empty());
458
459        // Test with single rectangle
460        let single_rect = Rectangle::new(Point::new(0.0, 0.0), Point::new(10.0, 10.0));
461        let single_quad = QuadPoints::from_rect(&single_rect);
462        assert_eq!(single_quad.points.len(), 8);
463
464        // Test with extreme coordinates
465        let extreme_rect = Rectangle::new(
466            Point::new(f64::MIN, f64::MIN),
467            Point::new(f64::MAX, f64::MAX),
468        );
469        let extreme_quad = QuadPoints::from_rect(&extreme_rect);
470        assert_eq!(extreme_quad.points.len(), 8);
471        assert_eq!(extreme_quad.points[0], f64::MIN);
472        assert_eq!(extreme_quad.points[4], f64::MAX);
473    }
474
475    #[test]
476    fn test_markup_type_debug_clone_copy() {
477        let markup_type = MarkupType::Highlight;
478
479        // Test Debug
480        let debug_str = format!("{markup_type:?}");
481        assert!(debug_str.contains("Highlight"));
482
483        // Test Clone
484        let cloned = markup_type;
485        assert!(matches!(cloned, MarkupType::Highlight));
486
487        // Test Copy
488        let copied: MarkupType = markup_type;
489        assert!(matches!(copied, MarkupType::Highlight));
490    }
491
492    #[test]
493    fn test_quad_points_debug_clone() {
494        let quad_points = QuadPoints {
495            points: vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
496        };
497
498        // Test Debug
499        let debug_str = format!("{quad_points:?}");
500        assert!(debug_str.contains("QuadPoints"));
501        assert!(debug_str.contains("1.0"));
502
503        // Test Clone
504        let cloned = quad_points.clone();
505        assert_eq!(cloned.points, quad_points.points);
506    }
507
508    #[test]
509    fn test_markup_annotation_debug_clone() {
510        let rect = Rectangle::new(Point::new(100.0, 100.0), Point::new(200.0, 120.0));
511        let markup = MarkupAnnotation::highlight(rect).with_author("Test Author");
512
513        // Test Debug
514        let debug_str = format!("{markup:?}");
515        assert!(debug_str.contains("MarkupAnnotation"));
516        assert!(debug_str.contains("Highlight"));
517
518        // Test Clone
519        let cloned = markup;
520        assert_eq!(cloned.author, Some("Test Author".to_string()));
521        assert!(matches!(cloned.markup_type, MarkupType::Highlight));
522    }
523
524    #[test]
525    fn test_markup_color_customization() {
526        let rect = Rectangle::new(Point::new(0.0, 0.0), Point::new(100.0, 20.0));
527
528        // Test with various color types
529        let colors = vec![
530            Color::Gray(0.5),
531            Color::Rgb(0.1, 0.2, 0.3),
532            Color::Cmyk(0.1, 0.2, 0.3, 0.4),
533        ];
534
535        for color in colors {
536            let markup = MarkupAnnotation::highlight(rect).with_color(color);
537
538            let annotation = markup.to_annotation();
539            let dict = annotation.to_dict();
540
541            // Verify color is set in dictionary
542            assert!(dict.get("C").is_some());
543
544            if let Some(Object::Array(color_array)) = dict.get("C") {
545                match color {
546                    Color::Gray(_) => assert_eq!(color_array.len(), 1),
547                    Color::Rgb(_, _, _) => assert_eq!(color_array.len(), 3),
548                    Color::Cmyk(_, _, _, _) => assert_eq!(color_array.len(), 4),
549                }
550            }
551        }
552    }
553
554    #[test]
555    fn test_markup_without_optional_fields() {
556        let rect = Rectangle::new(Point::new(100.0, 100.0), Point::new(200.0, 120.0));
557        let quad_points = QuadPoints::from_rect(&rect);
558
559        let markup = MarkupAnnotation::new(MarkupType::Underline, rect, quad_points);
560
561        // Verify optional fields are None
562        assert!(markup.author.is_none());
563        assert!(markup.subject.is_none());
564
565        let annotation = markup.to_annotation();
566        let dict = annotation.to_dict();
567
568        // Verify optional fields are not in dictionary
569        assert!(!dict.contains_key("T"));
570        assert!(!dict.contains_key("Subj"));
571
572        // Required fields should still be present
573        assert!(dict.contains_key("QuadPoints"));
574    }
575
576    #[test]
577    fn test_multiple_line_highlight() {
578        // Simulate highlighting text across multiple lines
579        let line_height = 15.0;
580        let lines = 5;
581        let mut rects = Vec::new();
582
583        for i in 0..lines {
584            let y_base = 700.0 - (i as f64 * line_height);
585            let rect = Rectangle::new(
586                Point::new(100.0, y_base),
587                Point::new(500.0 - (i as f64 * 20.0), y_base + 12.0),
588            );
589            rects.push(rect);
590        }
591
592        // Create bounding rectangle
593        let bounding_rect = Rectangle::new(
594            Point::new(100.0, 700.0 - ((lines - 1) as f64 * line_height)),
595            Point::new(500.0, 700.0 + 12.0),
596        );
597
598        let quad_points = QuadPoints::from_rects(&rects);
599        let expected_points_len = quad_points.points.len();
600        let markup = MarkupAnnotation::new(MarkupType::Highlight, bounding_rect, quad_points)
601            .with_contents("Multi-line highlight example")
602            .with_subject("Code section");
603
604        assert_eq!(expected_points_len, lines * 8);
605
606        let annotation = markup.to_annotation();
607        let dict = annotation.to_dict();
608
609        if let Some(Object::Array(points_array)) = dict.get("QuadPoints") {
610            assert_eq!(points_array.len(), lines * 8);
611        }
612    }
613
614    #[test]
615    fn test_markup_builder_pattern() {
616        let rect = Rectangle::new(Point::new(50.0, 50.0), Point::new(250.0, 70.0));
617
618        // Test chaining all builder methods
619        let markup = MarkupAnnotation::squiggly(rect)
620            .with_author("Reviewer")
621            .with_subject("Grammar")
622            .with_contents("Incorrect grammar in this sentence")
623            .with_color(Color::Rgb(1.0, 0.0, 0.5));
624
625        // Verify all properties were set
626        assert_eq!(markup.author, Some("Reviewer".to_string()));
627        assert_eq!(markup.subject, Some("Grammar".to_string()));
628        assert_eq!(
629            markup.annotation.contents,
630            Some("Incorrect grammar in this sentence".to_string())
631        );
632        assert!(matches!(
633            markup.annotation.color,
634            Some(Color::Rgb(1.0, 0.0, 0.5))
635        ));
636        assert!(matches!(markup.markup_type, MarkupType::Squiggly));
637    }
638}