Skip to main content

justpdf_core/annot/
builder.rs

1use crate::error::{JustPdfError, Result};
2use crate::object::{IndirectRef, PdfDict, PdfObject};
3use crate::page::Rect;
4use crate::writer::modify::DocumentModifier;
5
6use super::types::*;
7
8/// Builder for creating PDF annotations.
9pub struct AnnotationBuilder {
10    annot_type: AnnotationType,
11    rect: Rect,
12    contents: Option<String>,
13    color: Option<AnnotColor>,
14    border: Option<BorderStyle>,
15    flags: AnnotationFlags,
16    data: AnnotationData,
17}
18
19impl AnnotationBuilder {
20    fn new(annot_type: AnnotationType, rect: Rect, data: AnnotationData) -> Self {
21        Self {
22            annot_type,
23            rect,
24            contents: None,
25            color: None,
26            border: None,
27            flags: AnnotationFlags(AnnotationFlags::PRINT),
28            data,
29        }
30    }
31
32    // --- Constructors for specific annotation types ---
33
34    pub fn highlight(rect: Rect, quad_points: Vec<f64>, color: AnnotColor) -> Self {
35        let mut b = Self::new(
36            AnnotationType::Highlight,
37            rect,
38            AnnotationData::Markup { quad_points },
39        );
40        b.color = Some(color);
41        b
42    }
43
44    pub fn underline(rect: Rect, quad_points: Vec<f64>, color: AnnotColor) -> Self {
45        let mut b = Self::new(
46            AnnotationType::Underline,
47            rect,
48            AnnotationData::Markup { quad_points },
49        );
50        b.color = Some(color);
51        b
52    }
53
54    pub fn strike_out(rect: Rect, quad_points: Vec<f64>, color: AnnotColor) -> Self {
55        let mut b = Self::new(
56            AnnotationType::StrikeOut,
57            rect,
58            AnnotationData::Markup { quad_points },
59        );
60        b.color = Some(color);
61        b
62    }
63
64    pub fn squiggly(rect: Rect, quad_points: Vec<f64>, color: AnnotColor) -> Self {
65        let mut b = Self::new(
66            AnnotationType::Squiggly,
67            rect,
68            AnnotationData::Markup { quad_points },
69        );
70        b.color = Some(color);
71        b
72    }
73
74    pub fn text(rect: Rect, contents: &str) -> Self {
75        let mut b = Self::new(AnnotationType::Text, rect, AnnotationData::None);
76        b.contents = Some(contents.to_string());
77        b
78    }
79
80    pub fn free_text(rect: Rect, text: &str, da: &str) -> Self {
81        let mut b = Self::new(
82            AnnotationType::FreeText,
83            rect,
84            AnnotationData::FreeText {
85                da: da.to_string(),
86                justification: 0,
87            },
88        );
89        b.contents = Some(text.to_string());
90        b
91    }
92
93    pub fn line(start: (f64, f64), end: (f64, f64)) -> Self {
94        let rect = Rect {
95            llx: start.0.min(end.0),
96            lly: start.1.min(end.1),
97            urx: start.0.max(end.0),
98            ury: start.1.max(end.1),
99        };
100        Self::new(
101            AnnotationType::Line,
102            rect,
103            AnnotationData::Line {
104                start,
105                end,
106                line_endings: (LineEndingStyle::None, LineEndingStyle::None),
107                leader_line_length: 0.0,
108                leader_line_extension: 0.0,
109                caption: false,
110                interior_color: None,
111            },
112        )
113    }
114
115    pub fn square(rect: Rect) -> Self {
116        Self::new(
117            AnnotationType::Square,
118            rect,
119            AnnotationData::Shape {
120                vertices: Vec::new(),
121                interior_color: None,
122            },
123        )
124    }
125
126    pub fn circle(rect: Rect) -> Self {
127        Self::new(
128            AnnotationType::Circle,
129            rect,
130            AnnotationData::Shape {
131                vertices: Vec::new(),
132                interior_color: None,
133            },
134        )
135    }
136
137    pub fn polygon(rect: Rect, vertices: Vec<(f64, f64)>) -> Self {
138        Self::new(
139            AnnotationType::Polygon,
140            rect,
141            AnnotationData::Shape {
142                vertices,
143                interior_color: None,
144            },
145        )
146    }
147
148    pub fn polyline(rect: Rect, vertices: Vec<(f64, f64)>) -> Self {
149        Self::new(
150            AnnotationType::PolyLine,
151            rect,
152            AnnotationData::Shape {
153                vertices,
154                interior_color: None,
155            },
156        )
157    }
158
159    pub fn ink(rect: Rect, ink_list: Vec<Vec<(f64, f64)>>) -> Self {
160        Self::new(AnnotationType::Ink, rect, AnnotationData::Ink { ink_list })
161    }
162
163    pub fn stamp(rect: Rect, stamp_name: &str) -> Self {
164        Self::new(
165            AnnotationType::Stamp,
166            rect,
167            AnnotationData::Stamp {
168                icon_name: stamp_name.to_string(),
169            },
170        )
171    }
172
173    pub fn link_uri(rect: Rect, uri: &str) -> Self {
174        Self::new(
175            AnnotationType::Link,
176            rect,
177            AnnotationData::Link {
178                uri: Some(uri.to_string()),
179                dest: None,
180            },
181        )
182    }
183
184    pub fn link_goto(rect: Rect, dest: PdfObject) -> Self {
185        Self::new(
186            AnnotationType::Link,
187            rect,
188            AnnotationData::Link {
189                uri: None,
190                dest: Some(dest),
191            },
192        )
193    }
194
195    pub fn file_attachment(rect: Rect, fs_ref: IndirectRef, icon_name: &str) -> Self {
196        Self::new(
197            AnnotationType::FileAttachment,
198            rect,
199            AnnotationData::FileAttachment {
200                fs_ref: Some(fs_ref),
201                icon_name: icon_name.to_string(),
202            },
203        )
204    }
205
206    pub fn redact(rect: Rect) -> Self {
207        Self::new(
208            AnnotationType::Redact,
209            rect,
210            AnnotationData::Redact {
211                overlay_text: None,
212                repeat: false,
213                interior_color: None,
214            },
215        )
216    }
217
218    // --- Setters ---
219
220    pub fn contents(mut self, contents: &str) -> Self {
221        self.contents = Some(contents.to_string());
222        self
223    }
224
225    pub fn color(mut self, color: AnnotColor) -> Self {
226        self.color = Some(color);
227        self
228    }
229
230    pub fn border(mut self, border: BorderStyle) -> Self {
231        self.border = Some(border);
232        self
233    }
234
235    pub fn flags(mut self, flags: AnnotationFlags) -> Self {
236        self.flags = flags;
237        self
238    }
239
240    pub fn line_endings(mut self, start: LineEndingStyle, end: LineEndingStyle) -> Self {
241        if let AnnotationData::Line {
242            ref mut line_endings,
243            ..
244        } = self.data
245        {
246            *line_endings = (start, end);
247        }
248        self
249    }
250
251    pub fn interior_color(mut self, color: AnnotColor) -> Self {
252        match &mut self.data {
253            AnnotationData::Line {
254                interior_color,
255                ..
256            }
257            | AnnotationData::Shape {
258                interior_color,
259                ..
260            }
261            | AnnotationData::Redact {
262                interior_color,
263                ..
264            } => {
265                *interior_color = Some(color);
266            }
267            _ => {}
268        }
269        self
270    }
271
272    pub fn overlay_text(mut self, text: &str) -> Self {
273        if let AnnotationData::Redact {
274            overlay_text,
275            ..
276        } = &mut self.data
277        {
278            *overlay_text = Some(text.to_string());
279        }
280        self
281    }
282
283    pub fn justification(mut self, q: i64) -> Self {
284        if let AnnotationData::FreeText {
285            justification,
286            ..
287        } = &mut self.data
288        {
289            *justification = q;
290        }
291        self
292    }
293
294    /// Build the annotation dictionary.
295    pub fn build_dict(&self) -> PdfDict {
296        let mut dict = PdfDict::new();
297        dict.insert(b"Type".to_vec(), PdfObject::Name(b"Annot".to_vec()));
298        dict.insert(
299            b"Subtype".to_vec(),
300            PdfObject::Name(self.annot_type.to_name().to_vec()),
301        );
302        dict.insert(
303            b"Rect".to_vec(),
304            PdfObject::Array(vec![
305                PdfObject::Real(self.rect.llx),
306                PdfObject::Real(self.rect.lly),
307                PdfObject::Real(self.rect.urx),
308                PdfObject::Real(self.rect.ury),
309            ]),
310        );
311
312        if self.flags.0 != 0 {
313            dict.insert(b"F".to_vec(), PdfObject::Integer(self.flags.0 as i64));
314        }
315
316        if let Some(ref contents) = self.contents {
317            dict.insert(
318                b"Contents".to_vec(),
319                PdfObject::String(contents.as_bytes().to_vec()),
320            );
321        }
322
323        if let Some(ref color) = self.color {
324            dict.insert(b"C".to_vec(), PdfObject::Array(color.to_pdf_array()));
325        }
326
327        if let Some(ref border) = self.border {
328            let mut bs = PdfDict::new();
329            bs.insert(b"W".to_vec(), PdfObject::Real(border.width));
330            bs.insert(
331                b"S".to_vec(),
332                PdfObject::Name(border.style.to_name().to_vec()),
333            );
334            if !border.dash_pattern.is_empty() {
335                bs.insert(
336                    b"D".to_vec(),
337                    PdfObject::Array(
338                        border
339                            .dash_pattern
340                            .iter()
341                            .map(|&v| PdfObject::Real(v))
342                            .collect(),
343                    ),
344                );
345            }
346            dict.insert(b"BS".to_vec(), PdfObject::Dict(bs));
347        }
348
349        // Type-specific data
350        match &self.data {
351            AnnotationData::Markup { quad_points } => {
352                if !quad_points.is_empty() {
353                    dict.insert(
354                        b"QuadPoints".to_vec(),
355                        PdfObject::Array(
356                            quad_points.iter().map(|&v| PdfObject::Real(v)).collect(),
357                        ),
358                    );
359                }
360            }
361            AnnotationData::Line {
362                start,
363                end,
364                line_endings,
365                leader_line_length,
366                leader_line_extension,
367                caption,
368                interior_color,
369            } => {
370                dict.insert(
371                    b"L".to_vec(),
372                    PdfObject::Array(vec![
373                        PdfObject::Real(start.0),
374                        PdfObject::Real(start.1),
375                        PdfObject::Real(end.0),
376                        PdfObject::Real(end.1),
377                    ]),
378                );
379                dict.insert(
380                    b"LE".to_vec(),
381                    PdfObject::Array(vec![
382                        PdfObject::Name(line_endings.0.to_name().to_vec()),
383                        PdfObject::Name(line_endings.1.to_name().to_vec()),
384                    ]),
385                );
386                if *leader_line_length != 0.0 {
387                    dict.insert(b"LL".to_vec(), PdfObject::Real(*leader_line_length));
388                }
389                if *leader_line_extension != 0.0 {
390                    dict.insert(b"LLE".to_vec(), PdfObject::Real(*leader_line_extension));
391                }
392                if *caption {
393                    dict.insert(b"Cap".to_vec(), PdfObject::Bool(true));
394                }
395                if let Some(ic) = interior_color {
396                    dict.insert(b"IC".to_vec(), PdfObject::Array(ic.to_pdf_array()));
397                }
398            }
399            AnnotationData::Ink { ink_list } => {
400                let ink_arr: Vec<PdfObject> = ink_list
401                    .iter()
402                    .map(|stroke| {
403                        let coords: Vec<PdfObject> = stroke
404                            .iter()
405                            .flat_map(|&(x, y)| vec![PdfObject::Real(x), PdfObject::Real(y)])
406                            .collect();
407                        PdfObject::Array(coords)
408                    })
409                    .collect();
410                dict.insert(b"InkList".to_vec(), PdfObject::Array(ink_arr));
411            }
412            AnnotationData::Link { uri, dest } => {
413                if let Some(uri) = uri {
414                    let mut action = PdfDict::new();
415                    action.insert(b"S".to_vec(), PdfObject::Name(b"URI".to_vec()));
416                    action.insert(
417                        b"URI".to_vec(),
418                        PdfObject::String(uri.as_bytes().to_vec()),
419                    );
420                    dict.insert(b"A".to_vec(), PdfObject::Dict(action));
421                } else if let Some(dest) = dest {
422                    dict.insert(b"Dest".to_vec(), dest.clone());
423                }
424            }
425            AnnotationData::FreeText { da, justification } => {
426                dict.insert(b"DA".to_vec(), PdfObject::String(da.as_bytes().to_vec()));
427                if *justification != 0 {
428                    dict.insert(b"Q".to_vec(), PdfObject::Integer(*justification));
429                }
430            }
431            AnnotationData::FileAttachment { fs_ref, icon_name } => {
432                if let Some(r) = fs_ref {
433                    dict.insert(b"FS".to_vec(), PdfObject::Reference(r.clone()));
434                }
435                dict.insert(
436                    b"Name".to_vec(),
437                    PdfObject::Name(icon_name.as_bytes().to_vec()),
438                );
439            }
440            AnnotationData::Stamp { icon_name } => {
441                dict.insert(
442                    b"Name".to_vec(),
443                    PdfObject::Name(icon_name.as_bytes().to_vec()),
444                );
445            }
446            AnnotationData::Shape {
447                vertices,
448                interior_color,
449            } => {
450                if !vertices.is_empty() {
451                    let coords: Vec<PdfObject> = vertices
452                        .iter()
453                        .flat_map(|&(x, y)| vec![PdfObject::Real(x), PdfObject::Real(y)])
454                        .collect();
455                    dict.insert(b"Vertices".to_vec(), PdfObject::Array(coords));
456                }
457                if let Some(ic) = interior_color {
458                    dict.insert(b"IC".to_vec(), PdfObject::Array(ic.to_pdf_array()));
459                }
460            }
461            AnnotationData::Redact {
462                overlay_text,
463                repeat,
464                interior_color,
465            } => {
466                if let Some(text) = overlay_text {
467                    dict.insert(
468                        b"OverlayText".to_vec(),
469                        PdfObject::String(text.as_bytes().to_vec()),
470                    );
471                }
472                if *repeat {
473                    dict.insert(b"Repeat".to_vec(), PdfObject::Bool(true));
474                }
475                if let Some(ic) = interior_color {
476                    dict.insert(b"IC".to_vec(), PdfObject::Array(ic.to_pdf_array()));
477                }
478            }
479            AnnotationData::None => {}
480        }
481
482        dict
483    }
484}
485
486/// Add an annotation to a page.
487pub fn add_annotation(
488    modifier: &mut DocumentModifier,
489    page_obj_num: u32,
490    builder: AnnotationBuilder,
491) -> Result<IndirectRef> {
492    let annot_dict = builder.build_dict();
493
494    // Generate appearance stream
495    let ap_ref = super::appearance::generate_appearance(&annot_dict, modifier)?;
496
497    let mut final_dict = annot_dict;
498    if let Some(ap_ref) = ap_ref {
499        let mut ap_dict = PdfDict::new();
500        ap_dict.insert(b"N".to_vec(), PdfObject::Reference(ap_ref));
501        final_dict.insert(b"AP".to_vec(), PdfObject::Dict(ap_dict));
502    }
503
504    let annot_ref = modifier.add_object(PdfObject::Dict(final_dict));
505
506    // Add to page /Annots array
507    let page_obj = modifier
508        .find_object_pub(page_obj_num)
509        .cloned()
510        .unwrap_or(PdfObject::Null);
511
512    if let PdfObject::Dict(mut page_dict) = page_obj {
513        let mut annots = match page_dict.remove(b"Annots") {
514            Some(PdfObject::Array(arr)) => arr,
515            _ => Vec::new(),
516        };
517        annots.push(PdfObject::Reference(annot_ref.clone()));
518        page_dict.insert(b"Annots".to_vec(), PdfObject::Array(annots));
519        modifier.set_object(page_obj_num, PdfObject::Dict(page_dict));
520    }
521
522    Ok(annot_ref)
523}
524
525/// Delete an annotation from a page by index.
526pub fn delete_annotation(
527    modifier: &mut DocumentModifier,
528    page_obj_num: u32,
529    annot_index: usize,
530) -> Result<()> {
531    let page_obj = modifier
532        .find_object_pub(page_obj_num)
533        .cloned()
534        .unwrap_or(PdfObject::Null);
535
536    if let PdfObject::Dict(mut page_dict) = page_obj {
537        if let Some(PdfObject::Array(mut annots)) = page_dict.remove(b"Annots") {
538            if annot_index >= annots.len() {
539                return Err(JustPdfError::AnnotationError {
540                    detail: format!(
541                        "annotation index {annot_index} out of range ({})",
542                        annots.len()
543                    ),
544                });
545            }
546            annots.remove(annot_index);
547            if !annots.is_empty() {
548                page_dict.insert(b"Annots".to_vec(), PdfObject::Array(annots));
549            }
550            modifier.set_object(page_obj_num, PdfObject::Dict(page_dict));
551            Ok(())
552        } else {
553            Err(JustPdfError::AnnotationError {
554                detail: "page has no annotations".into(),
555            })
556        }
557    } else {
558        Err(JustPdfError::AnnotationError {
559            detail: "invalid page object".into(),
560        })
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn test_build_highlight_dict() {
570        let rect = Rect {
571            llx: 100.0,
572            lly: 200.0,
573            urx: 300.0,
574            ury: 220.0,
575        };
576        let qp = vec![100.0, 220.0, 300.0, 220.0, 100.0, 200.0, 300.0, 200.0];
577        let builder =
578            AnnotationBuilder::highlight(rect, qp, AnnotColor::Rgb(1.0, 1.0, 0.0));
579        let dict = builder.build_dict();
580
581        assert_eq!(dict.get_name(b"Subtype"), Some(b"Highlight".as_slice()));
582        assert!(dict.get_array(b"Rect").is_some());
583        assert!(dict.get_array(b"QuadPoints").is_some());
584        assert!(dict.get_array(b"C").is_some());
585    }
586
587    #[test]
588    fn test_build_ink_dict() {
589        let rect = Rect {
590            llx: 0.0,
591            lly: 0.0,
592            urx: 100.0,
593            ury: 100.0,
594        };
595        let ink_list = vec![vec![(10.0, 20.0), (30.0, 40.0), (50.0, 60.0)]];
596        let builder = AnnotationBuilder::ink(rect, ink_list)
597            .color(AnnotColor::Rgb(1.0, 0.0, 0.0))
598            .contents("Test ink");
599        let dict = builder.build_dict();
600
601        assert_eq!(dict.get_name(b"Subtype"), Some(b"Ink".as_slice()));
602        assert!(dict.get_array(b"InkList").is_some());
603        assert!(dict.get_array(b"C").is_some());
604        assert!(dict.get(b"Contents").is_some());
605    }
606
607    #[test]
608    fn test_build_line_dict() {
609        let builder = AnnotationBuilder::line((100.0, 100.0), (300.0, 300.0))
610            .line_endings(LineEndingStyle::OpenArrow, LineEndingStyle::ClosedArrow)
611            .color(AnnotColor::Rgb(0.0, 0.0, 1.0));
612        let dict = builder.build_dict();
613
614        assert_eq!(dict.get_name(b"Subtype"), Some(b"Line".as_slice()));
615        assert!(dict.get_array(b"L").is_some());
616        assert!(dict.get_array(b"LE").is_some());
617    }
618
619    #[test]
620    fn test_build_link_uri_dict() {
621        let rect = Rect {
622            llx: 72.0,
623            lly: 700.0,
624            urx: 200.0,
625            ury: 720.0,
626        };
627        let builder = AnnotationBuilder::link_uri(rect, "https://example.com");
628        let dict = builder.build_dict();
629
630        assert_eq!(dict.get_name(b"Subtype"), Some(b"Link".as_slice()));
631        let action = dict.get_dict(b"A").unwrap();
632        assert_eq!(action.get_name(b"S"), Some(b"URI".as_slice()));
633    }
634
635    #[test]
636    fn test_build_redact_dict() {
637        let rect = Rect {
638            llx: 100.0,
639            lly: 200.0,
640            urx: 400.0,
641            ury: 220.0,
642        };
643        let builder = AnnotationBuilder::redact(rect)
644            .overlay_text("REDACTED")
645            .interior_color(AnnotColor::Rgb(0.0, 0.0, 0.0));
646        let dict = builder.build_dict();
647
648        assert_eq!(dict.get_name(b"Subtype"), Some(b"Redact".as_slice()));
649        assert!(dict.get(b"OverlayText").is_some());
650        assert!(dict.get_array(b"IC").is_some());
651    }
652
653    #[test]
654    fn test_build_stamp_dict() {
655        let rect = Rect {
656            llx: 100.0,
657            lly: 600.0,
658            urx: 250.0,
659            ury: 650.0,
660        };
661        let builder = AnnotationBuilder::stamp(rect, "Approved");
662        let dict = builder.build_dict();
663
664        assert_eq!(dict.get_name(b"Subtype"), Some(b"Stamp".as_slice()));
665        assert_eq!(dict.get_name(b"Name"), Some(b"Approved".as_slice()));
666    }
667}