Skip to main content

pdf_annot/
builder.rs

1//! Annotation builder for creating PDF annotations via lopdf.
2//!
3//! Provides `AnnotationBuilder` for constructing annotation dictionaries
4//! with appearance streams and adding them to a PDF document.
5
6#[cfg(feature = "write")]
7use lopdf::{dictionary, Document, Object, ObjectId, Stream};
8
9#[cfg(feature = "write")]
10use crate::appearance_writer::{AppearanceColor, AppearanceStreamBuilder};
11#[cfg(feature = "write")]
12use crate::error::AnnotBuildError;
13
14/// Type alias for a custom appearance builder closure.
15#[cfg(feature = "write")]
16type AppearanceFn = Box<dyn FnOnce(&mut AppearanceStreamBuilder)>;
17
18/// PDF annotation rectangle in user-space coordinates.
19#[cfg(feature = "write")]
20#[derive(Debug, Clone, Copy)]
21pub struct AnnotRect {
22    pub x0: f64,
23    pub y0: f64,
24    pub x1: f64,
25    pub y1: f64,
26}
27
28#[cfg(feature = "write")]
29impl AnnotRect {
30    pub fn new(x0: f64, y0: f64, x1: f64, y1: f64) -> Self {
31        Self { x0, y0, x1, y1 }
32    }
33
34    pub fn width(&self) -> f64 {
35        (self.x1 - self.x0).abs()
36    }
37
38    pub fn height(&self) -> f64 {
39        (self.y1 - self.y0).abs()
40    }
41
42    fn as_array(&self) -> Object {
43        Object::Array(vec![
44            Object::Real(self.x0 as f32),
45            Object::Real(self.y0 as f32),
46            Object::Real(self.x1 as f32),
47            Object::Real(self.y1 as f32),
48        ])
49    }
50}
51
52/// The annotation subtype to create.
53#[cfg(feature = "write")]
54#[derive(Debug, Clone, Copy)]
55pub enum AnnotSubtype {
56    Square,
57    Circle,
58    Line,
59    Highlight,
60    Underline,
61    StrikeOut,
62    Squiggly,
63    FreeText,
64    Text,
65    Stamp,
66    Ink,
67    Polygon,
68    PolyLine,
69    Link,
70}
71
72#[cfg(feature = "write")]
73impl AnnotSubtype {
74    fn as_str(&self) -> &'static str {
75        match self {
76            Self::Square => "Square",
77            Self::Circle => "Circle",
78            Self::Line => "Line",
79            Self::Highlight => "Highlight",
80            Self::Underline => "Underline",
81            Self::StrikeOut => "StrikeOut",
82            Self::Squiggly => "Squiggly",
83            Self::FreeText => "FreeText",
84            Self::Text => "Text",
85            Self::Stamp => "Stamp",
86            Self::Ink => "Ink",
87            Self::Polygon => "Polygon",
88            Self::PolyLine => "PolyLine",
89            Self::Link => "Link",
90        }
91    }
92}
93
94/// Builder for creating a PDF annotation and adding it to a document.
95///
96/// # Example
97/// ```no_run
98/// use pdf_annot::builder::{AnnotationBuilder, AnnotSubtype, AnnotRect};
99///
100/// let mut doc = lopdf::Document::with_version("1.7");
101/// // ... add pages ...
102/// let annot_id = AnnotationBuilder::new(AnnotSubtype::Square, AnnotRect::new(100.0, 200.0, 300.0, 400.0))
103///     .color(1.0, 0.0, 0.0)
104///     .border_width(2.0)
105///     .contents("A red square")
106///     .build(&mut doc)
107///     .unwrap();
108/// ```
109#[cfg(feature = "write")]
110pub struct AnnotationBuilder {
111    subtype: AnnotSubtype,
112    rect: AnnotRect,
113    color: Option<AppearanceColor>,
114    interior_color: Option<AppearanceColor>,
115    opacity: Option<f64>,
116    border_width: f64,
117    contents: Option<String>,
118    flags: u32,
119    /// QuadPoints for markup annotations (Highlight, Underline, StrikeOut, Squiggly).
120    quad_points: Option<Vec<f64>>,
121    /// Line endpoints /L [x1 y1 x2 y2] for Line annotations.
122    line_endpoints: Option<[f64; 4]>,
123    /// Line endings /LE for Line annotations.
124    line_endings: Option<[LineEnding; 2]>,
125    /// InkList for Ink annotations: list of stroke paths.
126    ink_list: Option<Vec<Vec<f64>>>,
127    /// Vertices for Polygon/PolyLine annotations.
128    vertices: Option<Vec<f64>>,
129    /// Dash pattern for stroked annotations.
130    dash_pattern: Option<Vec<f64>>,
131    /// Default appearance string (/DA) for FreeText annotations.
132    default_appearance_str: Option<String>,
133    /// Text alignment (/Q) for FreeText: 0=left, 1=center, 2=right.
134    text_alignment: Option<i64>,
135    /// Icon name (/Name) for Text (sticky note) and Stamp annotations.
136    icon_name: Option<String>,
137    /// URI action (/A) for Link annotations.
138    uri_action: Option<String>,
139    /// Named destination (/Dest) for Link annotations.
140    destination: Option<String>,
141    /// Custom appearance builder function. If None, a default appearance is generated.
142    custom_appearance: Option<AppearanceFn>,
143}
144
145/// Standard stamp names per ISO 32000-2 §12.5.6.12.
146#[cfg(feature = "write")]
147#[derive(Debug, Clone, Copy)]
148pub enum StampName {
149    Approved,
150    Experimental,
151    NotApproved,
152    AsIs,
153    Expired,
154    NotForPublicRelease,
155    Confidential,
156    Final,
157    Sold,
158    Departmental,
159    ForComment,
160    TopSecret,
161    Draft,
162    ForPublicRelease,
163}
164
165#[cfg(feature = "write")]
166impl StampName {
167    fn as_str(&self) -> &'static str {
168        match self {
169            Self::Approved => "Approved",
170            Self::Experimental => "Experimental",
171            Self::NotApproved => "NotApproved",
172            Self::AsIs => "AsIs",
173            Self::Expired => "Expired",
174            Self::NotForPublicRelease => "NotForPublicRelease",
175            Self::Confidential => "Confidential",
176            Self::Final => "Final",
177            Self::Sold => "Sold",
178            Self::Departmental => "Departmental",
179            Self::ForComment => "ForComment",
180            Self::TopSecret => "TopSecret",
181            Self::Draft => "Draft",
182            Self::ForPublicRelease => "ForPublicRelease",
183        }
184    }
185}
186
187/// Standard icon names for Text (sticky note) annotations.
188#[cfg(feature = "write")]
189#[derive(Debug, Clone, Copy)]
190pub enum TextIcon {
191    Comment,
192    Key,
193    Note,
194    Help,
195    NewParagraph,
196    Paragraph,
197    Insert,
198}
199
200#[cfg(feature = "write")]
201impl TextIcon {
202    fn as_str(&self) -> &'static str {
203        match self {
204            Self::Comment => "Comment",
205            Self::Key => "Key",
206            Self::Note => "Note",
207            Self::Help => "Help",
208            Self::NewParagraph => "NewParagraph",
209            Self::Paragraph => "Paragraph",
210            Self::Insert => "Insert",
211        }
212    }
213}
214
215/// Line ending style for Line annotations (ISO 32000-2 Table 179).
216#[cfg(feature = "write")]
217#[derive(Debug, Clone, Copy)]
218pub enum LineEnding {
219    None,
220    Square,
221    Circle,
222    Diamond,
223    OpenArrow,
224    ClosedArrow,
225    Butt,
226    ROpenArrow,
227    RClosedArrow,
228    Slash,
229}
230
231#[cfg(feature = "write")]
232impl LineEnding {
233    fn as_str(&self) -> &'static str {
234        match self {
235            Self::None => "None",
236            Self::Square => "Square",
237            Self::Circle => "Circle",
238            Self::Diamond => "Diamond",
239            Self::OpenArrow => "OpenArrow",
240            Self::ClosedArrow => "ClosedArrow",
241            Self::Butt => "Butt",
242            Self::ROpenArrow => "ROpenArrow",
243            Self::RClosedArrow => "RClosedArrow",
244            Self::Slash => "Slash",
245        }
246    }
247}
248
249#[cfg(feature = "write")]
250impl AnnotationBuilder {
251    /// Create a new annotation builder for the given subtype and rectangle.
252    pub fn new(subtype: AnnotSubtype, rect: AnnotRect) -> Self {
253        Self {
254            subtype,
255            rect,
256            color: None,
257            interior_color: None,
258            opacity: None,
259            border_width: 1.0,
260            contents: None,
261            flags: 4, // Print flag set by default
262            quad_points: None,
263            line_endpoints: None,
264            line_endings: None,
265            ink_list: None,
266            vertices: None,
267            dash_pattern: None,
268            default_appearance_str: None,
269            text_alignment: None,
270            icon_name: None,
271            uri_action: None,
272            destination: None,
273            custom_appearance: None,
274        }
275    }
276
277    /// Create a FreeText annotation with the given text and font size.
278    pub fn free_text(rect: AnnotRect, text: &str, font_size: f64) -> Self {
279        let da = format!("/Helv {font_size} Tf 0 g");
280        let mut b = Self::new(AnnotSubtype::FreeText, rect).contents(text);
281        b.default_appearance_str = Some(da);
282        b
283    }
284
285    /// Create a Text (sticky note) annotation.
286    pub fn sticky_note(rect: AnnotRect, icon: TextIcon) -> Self {
287        let mut b = Self::new(AnnotSubtype::Text, rect);
288        b.icon_name = Some(icon.as_str().to_string());
289        b
290    }
291
292    /// Create a Stamp annotation with a standard stamp name.
293    pub fn stamp(rect: AnnotRect, name: StampName) -> Self {
294        let mut b = Self::new(AnnotSubtype::Stamp, rect);
295        b.icon_name = Some(name.as_str().to_string());
296        b
297    }
298
299    /// Create a Stamp annotation with a custom name.
300    pub fn stamp_custom(rect: AnnotRect, name: &str) -> Self {
301        let mut b = Self::new(AnnotSubtype::Stamp, rect);
302        b.icon_name = Some(name.to_string());
303        b
304    }
305
306    /// Create a Link annotation with a URI action.
307    pub fn link_uri(rect: AnnotRect, uri: &str) -> Self {
308        let mut b = Self::new(AnnotSubtype::Link, rect);
309        b.uri_action = Some(uri.to_string());
310        b.border_width = 0.0; // Links typically have no border.
311        b
312    }
313
314    /// Create a Link annotation with a named destination.
315    pub fn link_dest(rect: AnnotRect, dest: &str) -> Self {
316        let mut b = Self::new(AnnotSubtype::Link, rect);
317        b.destination = Some(dest.to_string());
318        b.border_width = 0.0;
319        b
320    }
321
322    /// Create a Square annotation.
323    pub fn square(rect: AnnotRect) -> Self {
324        Self::new(AnnotSubtype::Square, rect)
325    }
326
327    /// Create a Circle annotation.
328    pub fn circle(rect: AnnotRect) -> Self {
329        Self::new(AnnotSubtype::Circle, rect)
330    }
331
332    /// Create a Line annotation between two points.
333    ///
334    /// Automatically pads the bounding rect so it is never zero-area.
335    pub fn line(x1: f64, y1: f64, x2: f64, y2: f64) -> Self {
336        let pad = 1.0; // Minimum 1pt padding to prevent zero-area rect.
337        let mut min_x = x1.min(x2);
338        let mut min_y = y1.min(y2);
339        let mut max_x = x1.max(x2);
340        let mut max_y = y1.max(y2);
341        if (max_x - min_x).abs() < f64::EPSILON {
342            min_x -= pad;
343            max_x += pad;
344        }
345        if (max_y - min_y).abs() < f64::EPSILON {
346            min_y -= pad;
347            max_y += pad;
348        }
349        let rect = AnnotRect::new(min_x, min_y, max_x, max_y);
350        let mut b = Self::new(AnnotSubtype::Line, rect);
351        b.line_endpoints = Some([x1, y1, x2, y2]);
352        b
353    }
354
355    /// Create an Ink annotation from stroke paths.
356    pub fn ink(rect: AnnotRect, strokes: Vec<Vec<f64>>) -> Self {
357        let mut b = Self::new(AnnotSubtype::Ink, rect);
358        b.ink_list = Some(strokes);
359        b
360    }
361
362    /// Create a Polygon annotation from vertices.
363    pub fn polygon(rect: AnnotRect, vertices: Vec<f64>) -> Self {
364        let mut b = Self::new(AnnotSubtype::Polygon, rect);
365        b.vertices = Some(vertices);
366        b
367    }
368
369    /// Create a PolyLine annotation from vertices.
370    pub fn polyline(rect: AnnotRect, vertices: Vec<f64>) -> Self {
371        let mut b = Self::new(AnnotSubtype::PolyLine, rect);
372        b.vertices = Some(vertices);
373        b
374    }
375
376    /// Create a Highlight markup annotation.
377    pub fn highlight(rect: AnnotRect) -> Self {
378        Self::new(AnnotSubtype::Highlight, rect)
379            .color(1.0, 1.0, 0.0) // Yellow
380            .opacity(0.4)
381    }
382
383    /// Create an Underline markup annotation.
384    pub fn underline(rect: AnnotRect) -> Self {
385        Self::new(AnnotSubtype::Underline, rect).color(0.0, 0.0, 1.0)
386    }
387
388    /// Create a StrikeOut markup annotation.
389    pub fn strikeout(rect: AnnotRect) -> Self {
390        Self::new(AnnotSubtype::StrikeOut, rect).color(1.0, 0.0, 0.0)
391    }
392
393    /// Create a Squiggly markup annotation.
394    pub fn squiggly(rect: AnnotRect) -> Self {
395        Self::new(AnnotSubtype::Squiggly, rect).color(0.0, 0.8, 0.0)
396    }
397
398    /// Set the annotation color (RGB, 0.0–1.0).
399    pub fn color(mut self, r: f64, g: f64, b: f64) -> Self {
400        self.color = Some(AppearanceColor::new(r, g, b));
401        self
402    }
403
404    /// Set the interior (fill) color for annotations that support it.
405    pub fn interior_color(mut self, r: f64, g: f64, b: f64) -> Self {
406        self.interior_color = Some(AppearanceColor::new(r, g, b));
407        self
408    }
409
410    /// Set the opacity (CA/ca, 0.0–1.0).
411    pub fn opacity(mut self, alpha: f64) -> Self {
412        self.opacity = Some(alpha.clamp(0.0, 1.0));
413        self
414    }
415
416    /// Set the border/stroke width.
417    pub fn border_width(mut self, width: f64) -> Self {
418        self.border_width = width;
419        self
420    }
421
422    /// Set the /Contents text.
423    pub fn contents(mut self, text: impl Into<String>) -> Self {
424        self.contents = Some(text.into());
425        self
426    }
427
428    /// Set the annotation flags (raw u32).
429    pub fn flags(mut self, flags: u32) -> Self {
430        self.flags = flags;
431        self
432    }
433
434    /// Set text alignment for FreeText annotations (0=left, 1=center, 2=right).
435    pub fn alignment(mut self, q: i64) -> Self {
436        self.text_alignment = Some(q);
437        self
438    }
439
440    /// Set line endings for Line annotations.
441    pub fn line_endings(mut self, start: LineEnding, end: LineEnding) -> Self {
442        self.line_endings = Some([start, end]);
443        self
444    }
445
446    /// Set a dash pattern for stroked annotations.
447    pub fn dash(mut self, pattern: Vec<f64>) -> Self {
448        self.dash_pattern = Some(pattern);
449        self
450    }
451
452    /// Set QuadPoints for text markup annotations.
453    ///
454    /// Each quadrilateral is defined by 8 values in page coordinates:
455    /// `[x1,y1, x2,y2, x3,y3, x4,y4]` where the points define the
456    /// corners of the marked text region. Multiple quads can be
457    /// concatenated for multi-line selections.
458    pub fn quad_points(mut self, points: Vec<f64>) -> Self {
459        self.quad_points = Some(points);
460        self
461    }
462
463    /// Set QuadPoints from a simple rectangle (single quad).
464    pub fn quad_points_from_rect(self, rect: &AnnotRect) -> Self {
465        // PDF QuadPoints order: top-left, top-right, bottom-left, bottom-right
466        self.quad_points(vec![
467            rect.x0, rect.y1, // top-left
468            rect.x1, rect.y1, // top-right
469            rect.x0, rect.y0, // bottom-left
470            rect.x1, rect.y0, // bottom-right
471        ])
472    }
473
474    /// Provide a custom appearance builder closure.
475    pub fn appearance(mut self, f: impl FnOnce(&mut AppearanceStreamBuilder) + 'static) -> Self {
476        self.custom_appearance = Some(Box::new(f));
477        self
478    }
479
480    /// Build the annotation, add it to the document, and return the annotation object ID.
481    ///
482    /// This creates the annotation dictionary, generates the appearance stream,
483    /// and adds both as objects to the document. The annotation is NOT automatically
484    /// added to any page's /Annots array — use `add_to_page` for that.
485    pub fn build(mut self, doc: &mut Document) -> Result<ObjectId, AnnotBuildError> {
486        let w = self.rect.width();
487        let h = self.rect.height();
488        if w < f64::EPSILON || h < f64::EPSILON {
489            return Err(AnnotBuildError::InvalidRect);
490        }
491
492        // Extract custom appearance before building (avoids borrow issues).
493        let custom_appearance = self.custom_appearance.take();
494
495        // Build appearance stream.
496        let ap_stream_id = self.build_appearance(doc, w, h, custom_appearance)?;
497
498        // Build the annotation dictionary.
499        let mut annot_dict = dictionary! {
500            "Type" => "Annot",
501            "Subtype" => Object::Name(self.subtype.as_str().as_bytes().to_vec()),
502            "Rect" => self.rect.as_array(),
503            "F" => Object::Integer(self.flags as i64),
504        };
505
506        // Color (/C).
507        if let Some(ref c) = self.color {
508            annot_dict.set(
509                "C",
510                Object::Array(vec![
511                    Object::Real(c.r as f32),
512                    Object::Real(c.g as f32),
513                    Object::Real(c.b as f32),
514                ]),
515            );
516        }
517
518        // Interior color (/IC) — for Square, Circle.
519        if let Some(ref ic) = self.interior_color {
520            annot_dict.set(
521                "IC",
522                Object::Array(vec![
523                    Object::Real(ic.r as f32),
524                    Object::Real(ic.g as f32),
525                    Object::Real(ic.b as f32),
526                ]),
527            );
528        }
529
530        // Opacity.
531        if let Some(alpha) = self.opacity {
532            annot_dict.set("CA", Object::Real(alpha as f32));
533        }
534
535        // Contents.
536        if let Some(ref text) = self.contents {
537            annot_dict.set(
538                "Contents",
539                Object::String(text.as_bytes().to_vec(), lopdf::StringFormat::Literal),
540            );
541        }
542
543        // QuadPoints for markup annotations.
544        if let Some(ref qp) = self.quad_points {
545            let arr: Vec<Object> = qp.iter().map(|&v| Object::Real(v as f32)).collect();
546            annot_dict.set("QuadPoints", Object::Array(arr));
547        }
548
549        // Line endpoints (/L) for Line annotations.
550        if let Some(ref l) = self.line_endpoints {
551            annot_dict.set(
552                "L",
553                Object::Array(vec![
554                    Object::Real(l[0] as f32),
555                    Object::Real(l[1] as f32),
556                    Object::Real(l[2] as f32),
557                    Object::Real(l[3] as f32),
558                ]),
559            );
560        }
561
562        // Line endings (/LE).
563        if let Some(ref le) = self.line_endings {
564            annot_dict.set(
565                "LE",
566                Object::Array(vec![
567                    Object::Name(le[0].as_str().as_bytes().to_vec()),
568                    Object::Name(le[1].as_str().as_bytes().to_vec()),
569                ]),
570            );
571        }
572
573        // InkList for Ink annotations.
574        if let Some(ref ink) = self.ink_list {
575            let ink_arr: Vec<Object> = ink
576                .iter()
577                .map(|stroke| {
578                    Object::Array(stroke.iter().map(|&v| Object::Real(v as f32)).collect())
579                })
580                .collect();
581            annot_dict.set("InkList", Object::Array(ink_arr));
582        }
583
584        // Vertices for Polygon/PolyLine annotations.
585        if let Some(ref verts) = self.vertices {
586            let arr: Vec<Object> = verts.iter().map(|&v| Object::Real(v as f32)).collect();
587            annot_dict.set("Vertices", Object::Array(arr));
588        }
589
590        // Border style.
591        let has_dash = self.dash_pattern.is_some();
592        if (self.border_width - 1.0).abs() > f64::EPSILON || has_dash {
593            let mut bs = dictionary! {
594                "W" => Object::Real(self.border_width as f32),
595            };
596            if has_dash {
597                bs.set("S", Object::Name(b"D".to_vec()));
598                let d_arr: Vec<Object> = self
599                    .dash_pattern
600                    .as_ref()
601                    .expect("guarded by has_dash which checks is_some()")
602                    .iter()
603                    .map(|&v| Object::Real(v as f32))
604                    .collect();
605                bs.set("D", Object::Array(d_arr));
606            } else {
607                bs.set("S", Object::Name(b"S".to_vec()));
608            }
609            annot_dict.set("BS", Object::Dictionary(bs));
610        }
611
612        // Default appearance string (/DA) for FreeText.
613        if let Some(ref da) = self.default_appearance_str {
614            annot_dict.set(
615                "DA",
616                Object::String(da.as_bytes().to_vec(), lopdf::StringFormat::Literal),
617            );
618        }
619
620        // Text alignment (/Q) for FreeText.
621        if let Some(q) = self.text_alignment {
622            annot_dict.set("Q", Object::Integer(q));
623        }
624
625        // Icon name (/Name) for Text, Stamp.
626        if let Some(ref name) = self.icon_name {
627            annot_dict.set("Name", Object::Name(name.as_bytes().to_vec()));
628        }
629
630        // URI action (/A) for Link.
631        if let Some(ref uri) = self.uri_action {
632            let action = dictionary! {
633                "S" => "URI",
634                "URI" => Object::String(uri.as_bytes().to_vec(), lopdf::StringFormat::Literal),
635            };
636            annot_dict.set("A", Object::Dictionary(action));
637        }
638
639        // Named destination (/Dest) for Link.
640        if let Some(ref dest) = self.destination {
641            annot_dict.set(
642                "Dest",
643                Object::String(dest.as_bytes().to_vec(), lopdf::StringFormat::Literal),
644            );
645        }
646
647        // Normal appearance.
648        let ap = dictionary! {
649            "N" => Object::Reference(ap_stream_id),
650        };
651        annot_dict.set("AP", Object::Dictionary(ap));
652
653        Ok(doc.add_object(Object::Dictionary(annot_dict)))
654    }
655
656    /// Build appearance stream and add it as a Form XObject to the document.
657    fn build_appearance(
658        &self,
659        doc: &mut Document,
660        w: f64,
661        h: f64,
662        custom_appearance: Option<AppearanceFn>,
663    ) -> Result<ObjectId, AnnotBuildError> {
664        let mut builder = AppearanceStreamBuilder::new(w, h);
665
666        if let Some(custom) = custom_appearance {
667            custom(&mut builder);
668        } else {
669            self.default_appearance(&mut builder, w, h);
670        }
671
672        let content_bytes = builder
673            .encode()
674            .map_err(AnnotBuildError::AppearanceEncode)?;
675
676        let mut stream_dict = dictionary! {
677            "Type" => "XObject",
678            "Subtype" => "Form",
679            "BBox" => Object::Array(vec![
680                Object::Real(0.0),
681                Object::Real(0.0),
682                Object::Real(w as f32),
683                Object::Real(h as f32),
684            ]),
685        };
686
687        // Build resources for the form XObject.
688        let needs_multiply = matches!(self.subtype, AnnotSubtype::Highlight);
689        let needs_gs = self.opacity.is_some() || needs_multiply;
690        let needs_font = matches!(self.subtype, AnnotSubtype::FreeText | AnnotSubtype::Stamp);
691
692        if needs_gs || needs_font {
693            let mut resources = lopdf::Dictionary::new();
694
695            if needs_gs {
696                let mut gs_dict = dictionary! {
697                    "Type" => "ExtGState",
698                };
699                if let Some(alpha) = self.opacity {
700                    gs_dict.set("ca", Object::Real(alpha as f32));
701                    gs_dict.set("CA", Object::Real(alpha as f32));
702                }
703                if needs_multiply {
704                    gs_dict.set("BM", Object::Name(b"Multiply".to_vec()));
705                }
706                let gs_id = doc.add_object(Object::Dictionary(gs_dict));
707                let mut gs_res = lopdf::Dictionary::new();
708                gs_res.set("GS0", Object::Reference(gs_id));
709                resources.set("ExtGState", Object::Dictionary(gs_res));
710            }
711
712            if needs_font {
713                let font_dict = dictionary! {
714                    "Type" => "Font",
715                    "Subtype" => "Type1",
716                    "BaseFont" => "Helvetica",
717                };
718                let font_id = doc.add_object(Object::Dictionary(font_dict));
719                let mut font_res = lopdf::Dictionary::new();
720                font_res.set("Helv", Object::Reference(font_id));
721                resources.set("Font", Object::Dictionary(font_res));
722            }
723
724            stream_dict.set("Resources", Object::Dictionary(resources));
725        }
726
727        let stream = Stream::new(stream_dict, content_bytes);
728        Ok(doc.add_object(Object::Stream(stream)))
729    }
730
731    /// Generate a default appearance based on the annotation subtype.
732    fn default_appearance(&self, builder: &mut AppearanceStreamBuilder, w: f64, h: f64) {
733        let stroke = self.color.unwrap_or(AppearanceColor::new(0.0, 0.0, 0.0));
734        let needs_gs = self.opacity.is_some() || matches!(self.subtype, AnnotSubtype::Highlight);
735
736        if needs_gs {
737            builder.save_state();
738            builder.ops_push_raw(lopdf::content::Operation::new(
739                "gs",
740                vec![Object::Name(b"GS0".to_vec())],
741            ));
742        }
743
744        match self.subtype {
745            AnnotSubtype::Square => {
746                if let Some(ref fill) = self.interior_color {
747                    builder.filled_stroked_rect(fill, &stroke, self.border_width);
748                } else {
749                    builder.stroked_rect(&stroke, self.border_width);
750                }
751            }
752            AnnotSubtype::Circle => {
753                builder.save_state();
754                if let Some(ref fill) = self.interior_color {
755                    builder.set_fill_color(fill);
756                }
757                builder.set_stroke_color(&stroke);
758                builder.set_line_width(self.border_width);
759                builder.ellipse();
760                if self.interior_color.is_some() {
761                    builder.fill_and_stroke();
762                } else {
763                    builder.stroke();
764                }
765                builder.restore_state();
766            }
767            AnnotSubtype::Line => {
768                builder.save_state();
769                builder.set_stroke_color(&stroke);
770                builder.set_line_width(self.border_width);
771                if let Some(ref dash) = self.dash_pattern {
772                    builder.set_dash_pattern(dash, 0.0);
773                }
774                // Convert page coordinates to local form XObject coordinates.
775                if let Some(ref l) = self.line_endpoints {
776                    let lx1 = l[0] - self.rect.x0;
777                    let ly1 = l[1] - self.rect.y0;
778                    let lx2 = l[2] - self.rect.x0;
779                    let ly2 = l[3] - self.rect.y0;
780                    builder.line(lx1, ly1, lx2, ly2);
781                } else {
782                    builder.line(0.0, h / 2.0, w, h / 2.0);
783                }
784                builder.stroke();
785                builder.restore_state();
786            }
787            AnnotSubtype::Highlight => {
788                let fill = self.color.unwrap_or(AppearanceColor::new(1.0, 1.0, 0.0));
789                builder.filled_rect(&fill);
790            }
791            AnnotSubtype::Underline => {
792                builder.save_state();
793                builder.set_stroke_color(&stroke);
794                builder.set_line_width(self.border_width.max(0.5));
795                builder.line(0.0, 0.0, w, 0.0);
796                builder.stroke();
797                builder.restore_state();
798            }
799            AnnotSubtype::StrikeOut => {
800                builder.save_state();
801                builder.set_stroke_color(&stroke);
802                builder.set_line_width(self.border_width.max(0.5));
803                builder.line(0.0, h / 2.0, w, h / 2.0);
804                builder.stroke();
805                builder.restore_state();
806            }
807            AnnotSubtype::Squiggly => {
808                // Simplified squiggly: zigzag line at bottom.
809                builder.save_state();
810                builder.set_stroke_color(&stroke);
811                builder.set_line_width(self.border_width.max(0.5));
812                let step = 4.0;
813                let amp = 2.0;
814                builder.move_to(0.0, amp);
815                let mut x = 0.0;
816                let mut up = false;
817                while x < w {
818                    x += step;
819                    let y = if up { amp } else { 0.0 };
820                    builder.line_to(x.min(w), y);
821                    up = !up;
822                }
823                builder.stroke();
824                builder.restore_state();
825            }
826            AnnotSubtype::Ink => {
827                builder.save_state();
828                builder.set_stroke_color(&stroke);
829                builder.set_line_width(self.border_width);
830                if let Some(ref ink) = self.ink_list {
831                    for path in ink {
832                        if path.len() >= 2 {
833                            let x0 = path[0] - self.rect.x0;
834                            let y0 = path[1] - self.rect.y0;
835                            builder.move_to(x0, y0);
836                            let mut i = 2;
837                            while i + 1 < path.len() {
838                                let x = path[i] - self.rect.x0;
839                                let y = path[i + 1] - self.rect.y0;
840                                builder.line_to(x, y);
841                                i += 2;
842                            }
843                            builder.stroke();
844                        }
845                    }
846                }
847                builder.restore_state();
848            }
849            AnnotSubtype::Polygon | AnnotSubtype::PolyLine => {
850                builder.save_state();
851                if let Some(ref fill) = self.interior_color {
852                    builder.set_fill_color(fill);
853                }
854                builder.set_stroke_color(&stroke);
855                builder.set_line_width(self.border_width);
856                if let Some(ref dash) = self.dash_pattern {
857                    builder.set_dash_pattern(dash, 0.0);
858                }
859                if let Some(ref verts) = self.vertices {
860                    if verts.len() >= 2 {
861                        let x0 = verts[0] - self.rect.x0;
862                        let y0 = verts[1] - self.rect.y0;
863                        builder.move_to(x0, y0);
864                        let mut i = 2;
865                        while i + 1 < verts.len() {
866                            let x = verts[i] - self.rect.x0;
867                            let y = verts[i + 1] - self.rect.y0;
868                            builder.line_to(x, y);
869                            i += 2;
870                        }
871                    }
872                }
873                let is_polygon = matches!(self.subtype, AnnotSubtype::Polygon);
874                if is_polygon {
875                    builder.close_path();
876                    if self.interior_color.is_some() {
877                        builder.fill_and_stroke();
878                    } else {
879                        builder.stroke();
880                    }
881                } else {
882                    builder.stroke();
883                }
884                builder.restore_state();
885            }
886            AnnotSubtype::FreeText => {
887                // White background with border.
888                let white = AppearanceColor::new(1.0, 1.0, 1.0);
889                builder.filled_stroked_rect(&white, &stroke, self.border_width);
890                // Text is rendered via /DA by the viewer; the appearance stream
891                // provides the background rectangle.
892                if let Some(ref text) = self.contents {
893                    let text_color = self.color.unwrap_or(AppearanceColor::new(0.0, 0.0, 0.0));
894                    let margin = self.border_width + 2.0;
895                    builder.text(text, "Helv", 12.0, margin, h - margin - 12.0, &text_color);
896                }
897            }
898            AnnotSubtype::Text => {
899                // Sticky note icon — simplified as a filled square with a border.
900                let fill = AppearanceColor::new(1.0, 1.0, 0.6); // Light yellow
901                builder.filled_stroked_rect(&fill, &stroke, self.border_width);
902            }
903            AnnotSubtype::Stamp => {
904                // Stamp: red border with text.
905                let red = AppearanceColor::new(1.0, 0.0, 0.0);
906                builder.stroked_rect(&red, 2.0);
907                if let Some(ref name) = self.icon_name {
908                    builder.text(name, "Helv", 18.0, 4.0, h / 2.0 - 9.0, &red);
909                }
910            }
911            AnnotSubtype::Link => {
912                // Links are typically invisible — no appearance needed.
913                // Empty appearance stream (viewer draws the link area).
914            }
915        }
916
917        if needs_gs {
918            builder.restore_state();
919        }
920    }
921}
922
923#[cfg(feature = "write")]
924enum AnnotsAction {
925    SetArray(Vec<Object>),
926    AppendIndirect(ObjectId),
927}
928
929/// Add an annotation reference to a page's /Annots array.
930///
931/// Handles both inline arrays and indirect (referenced) arrays.  When the
932/// indirect Annots array cannot be resolved (e.g. it lives in a compressed
933/// ObjStm that lopdf failed to expand), falls back to replacing the /Annots
934/// entry with a new inline array so the annotation is never silently dropped.
935#[cfg(feature = "write")]
936pub fn add_annotation_to_page(
937    doc: &mut Document,
938    page_num: u32,
939    annot_id: ObjectId,
940) -> Result<(), AnnotBuildError> {
941    let pages = doc.get_pages();
942    let page_count = pages.len();
943    let page_id = *pages
944        .get(&page_num)
945        .ok_or(AnnotBuildError::PageOutOfRange(page_num, page_count))?;
946
947    // Read the current /Annots entry using get_dictionary (follows indirect
948    // refs, handles compressed-stream pages).
949    let annots_action = {
950        match doc.get_dictionary(page_id) {
951            Ok(page_dict) => match page_dict.get(b"Annots").ok() {
952                Some(Object::Array(arr)) => {
953                    let mut new_arr = arr.clone();
954                    new_arr.push(Object::Reference(annot_id));
955                    AnnotsAction::SetArray(new_arr)
956                }
957                Some(Object::Reference(r)) => AnnotsAction::AppendIndirect(*r),
958                _ => AnnotsAction::SetArray(vec![Object::Reference(annot_id)]),
959            },
960            // Page dict not accessible: still attempt to set an inline Annots.
961            Err(_) => AnnotsAction::SetArray(vec![Object::Reference(annot_id)]),
962        }
963    };
964
965    match annots_action {
966        AnnotsAction::SetArray(arr) => {
967            // Propagate failure: if the page dict cannot be mutated (e.g. the
968            // page was extracted from a fully-compressed ObjStm and lopdf has
969            // trouble writing it back), return an explicit error instead of
970            // silently dropping the annotation.  Fixes #470.
971            if let Ok(page_dict) = doc.get_dictionary_mut(page_id) {
972                page_dict.set("Annots", Object::Array(arr));
973            } else {
974                return Err(AnnotBuildError::PageMutationFailed);
975            }
976        }
977        AnnotsAction::AppendIndirect(annots_ref) => {
978            // Attempt to mutate the indirect array in place.
979            let appended = {
980                if let Ok(Object::Array(ref mut arr)) = doc.get_object_mut(annots_ref) {
981                    arr.push(Object::Reference(annot_id));
982                    true
983                } else {
984                    false
985                }
986            };
987
988            if !appended {
989                // Fallback: indirect array not accessible (e.g. lives in a
990                // compressed ObjStm). Try a read-only access first — lopdf can
991                // often decompress ObjStm objects for reading even when it
992                // cannot hand out a mutable reference.  This preserves existing
993                // annotations instead of silently dropping them. Fixes #466 bug 7.
994                let existing: Vec<Object> = match doc.get_object(annots_ref) {
995                    Ok(Object::Array(ref arr)) => arr.clone(),
996                    _ => Vec::new(),
997                };
998                let mut new_annots = existing;
999                new_annots.push(Object::Reference(annot_id));
1000                if let Ok(page_dict) = doc.get_dictionary_mut(page_id) {
1001                    page_dict.set("Annots", Object::Array(new_annots));
1002                } else {
1003                    return Err(AnnotBuildError::PageMutationFailed);
1004                }
1005            }
1006        }
1007    }
1008
1009    Ok(())
1010}
1011
1012#[cfg(all(test, feature = "write"))]
1013mod tests {
1014    use super::*;
1015
1016    fn make_test_doc() -> Document {
1017        let mut doc = Document::with_version("1.7");
1018        let pages_id = doc.new_object_id();
1019
1020        let content_data = b"BT /F1 12 Tf (Test) Tj ET".to_vec();
1021        let content_stream = Stream::new(dictionary! {}, content_data);
1022        let content_id = doc.add_object(Object::Stream(content_stream));
1023
1024        let page_dict = dictionary! {
1025            "Type" => "Page",
1026            "Parent" => Object::Reference(pages_id),
1027            "MediaBox" => Object::Array(vec![
1028                Object::Integer(0), Object::Integer(0),
1029                Object::Integer(612), Object::Integer(792),
1030            ]),
1031            "Contents" => Object::Reference(content_id),
1032            "Resources" => Object::Dictionary(lopdf::Dictionary::new()),
1033        };
1034        let page_id = doc.add_object(Object::Dictionary(page_dict));
1035
1036        let pages_dict = dictionary! {
1037            "Type" => "Pages",
1038            "Count" => Object::Integer(1),
1039            "Kids" => Object::Array(vec![Object::Reference(page_id)]),
1040        };
1041        doc.objects.insert(pages_id, Object::Dictionary(pages_dict));
1042
1043        let catalog = dictionary! {
1044            "Type" => "Catalog",
1045            "Pages" => Object::Reference(pages_id),
1046        };
1047        let catalog_id = doc.add_object(Object::Dictionary(catalog));
1048        doc.trailer.set("Root", Object::Reference(catalog_id));
1049
1050        doc
1051    }
1052
1053    #[test]
1054    fn build_square_annotation() {
1055        let mut doc = make_test_doc();
1056        let rect = AnnotRect::new(100.0, 200.0, 300.0, 400.0);
1057        let annot_id = AnnotationBuilder::new(AnnotSubtype::Square, rect)
1058            .color(1.0, 0.0, 0.0)
1059            .border_width(2.0)
1060            .contents("Red square")
1061            .build(&mut doc)
1062            .unwrap();
1063
1064        let annot = doc.get_object(annot_id).unwrap();
1065        if let Object::Dictionary(d) = annot {
1066            assert_eq!(
1067                d.get(b"Subtype").unwrap(),
1068                &Object::Name(b"Square".to_vec())
1069            );
1070            assert!(d.get(b"AP").is_ok());
1071            assert!(d.get(b"C").is_ok());
1072        } else {
1073            panic!("Expected dictionary");
1074        }
1075    }
1076
1077    #[test]
1078    fn build_circle_annotation() {
1079        let mut doc = make_test_doc();
1080        let rect = AnnotRect::new(50.0, 50.0, 150.0, 150.0);
1081        let annot_id = AnnotationBuilder::new(AnnotSubtype::Circle, rect)
1082            .color(0.0, 0.0, 1.0)
1083            .interior_color(0.8, 0.8, 1.0)
1084            .build(&mut doc)
1085            .unwrap();
1086
1087        let annot = doc.get_object(annot_id).unwrap();
1088        if let Object::Dictionary(d) = annot {
1089            assert_eq!(
1090                d.get(b"Subtype").unwrap(),
1091                &Object::Name(b"Circle".to_vec())
1092            );
1093            assert!(d.get(b"IC").is_ok());
1094        } else {
1095            panic!("Expected dictionary");
1096        }
1097    }
1098
1099    #[test]
1100    fn build_with_opacity() {
1101        let mut doc = make_test_doc();
1102        let rect = AnnotRect::new(0.0, 0.0, 100.0, 100.0);
1103        let annot_id = AnnotationBuilder::new(AnnotSubtype::Square, rect)
1104            .opacity(0.5)
1105            .build(&mut doc)
1106            .unwrap();
1107
1108        let annot = doc.get_object(annot_id).unwrap();
1109        if let Object::Dictionary(d) = annot {
1110            let ca = d.get(b"CA").unwrap();
1111            assert_eq!(ca, &Object::Real(0.5));
1112        } else {
1113            panic!("Expected dictionary");
1114        }
1115    }
1116
1117    #[test]
1118    fn reject_zero_area_rect() {
1119        let mut doc = make_test_doc();
1120        let rect = AnnotRect::new(100.0, 200.0, 100.0, 400.0); // zero width
1121        let result = AnnotationBuilder::new(AnnotSubtype::Square, rect).build(&mut doc);
1122        assert!(result.is_err());
1123    }
1124
1125    #[test]
1126    fn add_annotation_to_page_creates_annots() {
1127        let mut doc = make_test_doc();
1128        let rect = AnnotRect::new(10.0, 10.0, 50.0, 50.0);
1129        let annot_id = AnnotationBuilder::new(AnnotSubtype::Square, rect)
1130            .build(&mut doc)
1131            .unwrap();
1132
1133        add_annotation_to_page(&mut doc, 1, annot_id).unwrap();
1134
1135        // Verify page now has /Annots.
1136        let pages = doc.get_pages();
1137        let page_id = pages[&1];
1138        if let Object::Dictionary(d) = doc.get_object(page_id).unwrap() {
1139            let annots = d.get(b"Annots").unwrap();
1140            if let Object::Array(arr) = annots {
1141                assert_eq!(arr.len(), 1);
1142                assert_eq!(arr[0], Object::Reference(annot_id));
1143            } else {
1144                panic!("Expected array");
1145            }
1146        }
1147    }
1148
1149    #[test]
1150    fn add_annotation_appends_to_existing_annots() {
1151        let mut doc = make_test_doc();
1152        let rect1 = AnnotRect::new(10.0, 10.0, 50.0, 50.0);
1153        let rect2 = AnnotRect::new(60.0, 60.0, 100.0, 100.0);
1154
1155        let id1 = AnnotationBuilder::new(AnnotSubtype::Square, rect1)
1156            .build(&mut doc)
1157            .unwrap();
1158        let id2 = AnnotationBuilder::new(AnnotSubtype::Circle, rect2)
1159            .build(&mut doc)
1160            .unwrap();
1161
1162        add_annotation_to_page(&mut doc, 1, id1).unwrap();
1163        add_annotation_to_page(&mut doc, 1, id2).unwrap();
1164
1165        let pages = doc.get_pages();
1166        let page_id = pages[&1];
1167        if let Object::Dictionary(d) = doc.get_object(page_id).unwrap() {
1168            if let Object::Array(arr) = d.get(b"Annots").unwrap() {
1169                assert_eq!(arr.len(), 2);
1170            } else {
1171                panic!("Expected array");
1172            }
1173        }
1174    }
1175
1176    #[test]
1177    fn invalid_page_returns_error() {
1178        let mut doc = make_test_doc();
1179        let rect = AnnotRect::new(10.0, 10.0, 50.0, 50.0);
1180        let annot_id = AnnotationBuilder::new(AnnotSubtype::Square, rect)
1181            .build(&mut doc)
1182            .unwrap();
1183
1184        let result = add_annotation_to_page(&mut doc, 99, annot_id);
1185        assert!(result.is_err());
1186    }
1187
1188    #[test]
1189    fn highlight_annotation() {
1190        let mut doc = make_test_doc();
1191        let rect = AnnotRect::new(72.0, 700.0, 400.0, 712.0);
1192        let annot_id = AnnotationBuilder::new(AnnotSubtype::Highlight, rect)
1193            .color(1.0, 1.0, 0.0)
1194            .opacity(0.4)
1195            .build(&mut doc)
1196            .unwrap();
1197
1198        let annot = doc.get_object(annot_id).unwrap();
1199        if let Object::Dictionary(d) = annot {
1200            assert_eq!(
1201                d.get(b"Subtype").unwrap(),
1202                &Object::Name(b"Highlight".to_vec())
1203            );
1204        } else {
1205            panic!("Expected dictionary");
1206        }
1207    }
1208
1209    #[test]
1210    fn custom_appearance() {
1211        let mut doc = make_test_doc();
1212        let rect = AnnotRect::new(0.0, 0.0, 100.0, 100.0);
1213        let annot_id = AnnotationBuilder::new(AnnotSubtype::Square, rect)
1214            .appearance(|b| {
1215                let red = AppearanceColor::new(1.0, 0.0, 0.0);
1216                b.filled_rect(&red);
1217            })
1218            .build(&mut doc)
1219            .unwrap();
1220
1221        assert!(doc.get_object(annot_id).is_ok());
1222    }
1223
1224    // --- Issue #303 tests: Markup annotations ---
1225
1226    #[test]
1227    fn highlight_with_quad_points() {
1228        let mut doc = make_test_doc();
1229        let rect = AnnotRect::new(72.0, 700.0, 400.0, 712.0);
1230        let annot_id = AnnotationBuilder::highlight(rect)
1231            .quad_points_from_rect(&rect)
1232            .build(&mut doc)
1233            .unwrap();
1234
1235        let annot = doc.get_object(annot_id).unwrap();
1236        if let Object::Dictionary(d) = annot {
1237            assert_eq!(
1238                d.get(b"Subtype").unwrap(),
1239                &Object::Name(b"Highlight".to_vec())
1240            );
1241            // Check QuadPoints present.
1242            let qp = d.get(b"QuadPoints").unwrap();
1243            if let Object::Array(arr) = qp {
1244                assert_eq!(arr.len(), 8); // Single quad = 8 values.
1245            } else {
1246                panic!("Expected QuadPoints array");
1247            }
1248            // Check opacity is set.
1249            assert!(d.get(b"CA").is_ok());
1250        } else {
1251            panic!("Expected dictionary");
1252        }
1253    }
1254
1255    #[test]
1256    fn highlight_has_multiply_blend() {
1257        let mut doc = make_test_doc();
1258        let rect = AnnotRect::new(72.0, 700.0, 400.0, 712.0);
1259        let annot_id = AnnotationBuilder::highlight(rect).build(&mut doc).unwrap();
1260
1261        // Get the appearance stream and verify its resources contain BM /Multiply.
1262        let annot = doc.get_object(annot_id).unwrap();
1263        if let Object::Dictionary(d) = annot {
1264            let ap = d.get(b"AP").unwrap();
1265            if let Object::Dictionary(ap_dict) = ap {
1266                let n_ref = ap_dict.get(b"N").unwrap();
1267                if let Object::Reference(stream_id) = n_ref {
1268                    let stream = doc.get_object(*stream_id).unwrap();
1269                    if let Object::Stream(s) = stream {
1270                        let res = s.dict.get(b"Resources").unwrap();
1271                        if let Object::Dictionary(res_dict) = res {
1272                            let gs = res_dict.get(b"ExtGState").unwrap();
1273                            if let Object::Dictionary(gs_dict) = gs {
1274                                let gs0_ref = gs_dict.get(b"GS0").unwrap();
1275                                if let Object::Reference(gs0_id) = gs0_ref {
1276                                    let gs0 = doc.get_object(*gs0_id).unwrap();
1277                                    if let Object::Dictionary(gs0_dict) = gs0 {
1278                                        assert_eq!(
1279                                            gs0_dict.get(b"BM").unwrap(),
1280                                            &Object::Name(b"Multiply".to_vec())
1281                                        );
1282                                        return;
1283                                    }
1284                                }
1285                            }
1286                        }
1287                    }
1288                }
1289            }
1290        }
1291        panic!("Could not find BM /Multiply in ExtGState");
1292    }
1293
1294    #[test]
1295    fn underline_convenience() {
1296        let mut doc = make_test_doc();
1297        let rect = AnnotRect::new(72.0, 700.0, 400.0, 712.0);
1298        let annot_id = AnnotationBuilder::underline(rect)
1299            .quad_points_from_rect(&rect)
1300            .build(&mut doc)
1301            .unwrap();
1302
1303        let annot = doc.get_object(annot_id).unwrap();
1304        if let Object::Dictionary(d) = annot {
1305            assert_eq!(
1306                d.get(b"Subtype").unwrap(),
1307                &Object::Name(b"Underline".to_vec())
1308            );
1309            assert!(d.get(b"QuadPoints").is_ok());
1310        } else {
1311            panic!("Expected dictionary");
1312        }
1313    }
1314
1315    #[test]
1316    fn strikeout_convenience() {
1317        let mut doc = make_test_doc();
1318        let rect = AnnotRect::new(72.0, 700.0, 400.0, 712.0);
1319        let annot_id = AnnotationBuilder::strikeout(rect).build(&mut doc).unwrap();
1320
1321        let annot = doc.get_object(annot_id).unwrap();
1322        if let Object::Dictionary(d) = annot {
1323            assert_eq!(
1324                d.get(b"Subtype").unwrap(),
1325                &Object::Name(b"StrikeOut".to_vec())
1326            );
1327        } else {
1328            panic!("Expected dictionary");
1329        }
1330    }
1331
1332    #[test]
1333    fn squiggly_convenience() {
1334        let mut doc = make_test_doc();
1335        let rect = AnnotRect::new(72.0, 700.0, 400.0, 712.0);
1336        let annot_id = AnnotationBuilder::squiggly(rect).build(&mut doc).unwrap();
1337
1338        let annot = doc.get_object(annot_id).unwrap();
1339        if let Object::Dictionary(d) = annot {
1340            assert_eq!(
1341                d.get(b"Subtype").unwrap(),
1342                &Object::Name(b"Squiggly".to_vec())
1343            );
1344        } else {
1345            panic!("Expected dictionary");
1346        }
1347    }
1348
1349    #[test]
1350    fn multi_quad_points() {
1351        let mut doc = make_test_doc();
1352        let rect = AnnotRect::new(72.0, 688.0, 400.0, 712.0);
1353        // Two quads for two lines of text.
1354        let qp = vec![
1355            72.0, 712.0, 400.0, 712.0, 72.0, 700.0, 400.0, 700.0, // line 1
1356            72.0, 700.0, 300.0, 700.0, 72.0, 688.0, 300.0, 688.0, // line 2
1357        ];
1358        let annot_id = AnnotationBuilder::highlight(rect)
1359            .quad_points(qp)
1360            .build(&mut doc)
1361            .unwrap();
1362
1363        let annot = doc.get_object(annot_id).unwrap();
1364        if let Object::Dictionary(d) = annot {
1365            if let Object::Array(arr) = d.get(b"QuadPoints").unwrap() {
1366                assert_eq!(arr.len(), 16); // 2 quads × 8 values.
1367            } else {
1368                panic!("Expected QuadPoints array");
1369            }
1370        }
1371    }
1372
1373    // --- Issue #304 tests: Geometric annotations ---
1374
1375    #[test]
1376    fn square_convenience() {
1377        let mut doc = make_test_doc();
1378        let rect = AnnotRect::new(100.0, 100.0, 200.0, 200.0);
1379        let annot_id = AnnotationBuilder::square(rect)
1380            .color(0.0, 0.0, 1.0)
1381            .interior_color(0.9, 0.9, 1.0)
1382            .border_width(2.0)
1383            .build(&mut doc)
1384            .unwrap();
1385
1386        let annot = doc.get_object(annot_id).unwrap();
1387        if let Object::Dictionary(d) = annot {
1388            assert_eq!(
1389                d.get(b"Subtype").unwrap(),
1390                &Object::Name(b"Square".to_vec())
1391            );
1392            assert!(d.get(b"IC").is_ok());
1393        } else {
1394            panic!("Expected dictionary");
1395        }
1396    }
1397
1398    #[test]
1399    fn circle_convenience() {
1400        let mut doc = make_test_doc();
1401        let rect = AnnotRect::new(50.0, 50.0, 150.0, 150.0);
1402        let annot_id = AnnotationBuilder::circle(rect)
1403            .color(1.0, 0.0, 0.0)
1404            .build(&mut doc)
1405            .unwrap();
1406
1407        let annot = doc.get_object(annot_id).unwrap();
1408        if let Object::Dictionary(d) = annot {
1409            assert_eq!(
1410                d.get(b"Subtype").unwrap(),
1411                &Object::Name(b"Circle".to_vec())
1412            );
1413        } else {
1414            panic!("Expected dictionary");
1415        }
1416    }
1417
1418    #[test]
1419    fn line_annotation_with_endpoints() {
1420        let mut doc = make_test_doc();
1421        let annot_id = AnnotationBuilder::line(100.0, 200.0, 400.0, 600.0)
1422            .color(1.0, 0.0, 0.0)
1423            .border_width(2.0)
1424            .build(&mut doc)
1425            .unwrap();
1426
1427        let annot = doc.get_object(annot_id).unwrap();
1428        if let Object::Dictionary(d) = annot {
1429            assert_eq!(d.get(b"Subtype").unwrap(), &Object::Name(b"Line".to_vec()));
1430            // Check /L array present.
1431            let l = d.get(b"L").unwrap();
1432            if let Object::Array(arr) = l {
1433                assert_eq!(arr.len(), 4);
1434            } else {
1435                panic!("Expected /L array");
1436            }
1437        } else {
1438            panic!("Expected dictionary");
1439        }
1440    }
1441
1442    #[test]
1443    fn line_with_endings() {
1444        let mut doc = make_test_doc();
1445        let annot_id = AnnotationBuilder::line(100.0, 300.0, 500.0, 300.0)
1446            .line_endings(LineEnding::ClosedArrow, LineEnding::OpenArrow)
1447            .build(&mut doc)
1448            .unwrap();
1449
1450        let annot = doc.get_object(annot_id).unwrap();
1451        if let Object::Dictionary(d) = annot {
1452            let le = d.get(b"LE").unwrap();
1453            if let Object::Array(arr) = le {
1454                assert_eq!(arr.len(), 2);
1455                assert_eq!(arr[0], Object::Name(b"ClosedArrow".to_vec()));
1456                assert_eq!(arr[1], Object::Name(b"OpenArrow".to_vec()));
1457            } else {
1458                panic!("Expected /LE array");
1459            }
1460        } else {
1461            panic!("Expected dictionary");
1462        }
1463    }
1464
1465    #[test]
1466    fn ink_annotation() {
1467        let mut doc = make_test_doc();
1468        let rect = AnnotRect::new(50.0, 50.0, 200.0, 200.0);
1469        let strokes = vec![
1470            vec![60.0, 60.0, 100.0, 150.0, 180.0, 80.0],
1471            vec![70.0, 70.0, 120.0, 160.0],
1472        ];
1473        let annot_id = AnnotationBuilder::ink(rect, strokes)
1474            .color(0.0, 0.5, 0.0)
1475            .border_width(3.0)
1476            .build(&mut doc)
1477            .unwrap();
1478
1479        let annot = doc.get_object(annot_id).unwrap();
1480        if let Object::Dictionary(d) = annot {
1481            assert_eq!(d.get(b"Subtype").unwrap(), &Object::Name(b"Ink".to_vec()));
1482            let ink = d.get(b"InkList").unwrap();
1483            if let Object::Array(arr) = ink {
1484                assert_eq!(arr.len(), 2); // Two strokes.
1485            } else {
1486                panic!("Expected InkList array");
1487            }
1488        } else {
1489            panic!("Expected dictionary");
1490        }
1491    }
1492
1493    #[test]
1494    fn polygon_annotation() {
1495        let mut doc = make_test_doc();
1496        let rect = AnnotRect::new(100.0, 100.0, 300.0, 300.0);
1497        let verts = vec![100.0, 100.0, 300.0, 100.0, 200.0, 300.0];
1498        let annot_id = AnnotationBuilder::polygon(rect, verts)
1499            .color(0.0, 0.0, 1.0)
1500            .interior_color(0.8, 0.8, 1.0)
1501            .build(&mut doc)
1502            .unwrap();
1503
1504        let annot = doc.get_object(annot_id).unwrap();
1505        if let Object::Dictionary(d) = annot {
1506            assert_eq!(
1507                d.get(b"Subtype").unwrap(),
1508                &Object::Name(b"Polygon".to_vec())
1509            );
1510            let v = d.get(b"Vertices").unwrap();
1511            if let Object::Array(arr) = v {
1512                assert_eq!(arr.len(), 6);
1513            } else {
1514                panic!("Expected Vertices array");
1515            }
1516        } else {
1517            panic!("Expected dictionary");
1518        }
1519    }
1520
1521    #[test]
1522    fn polyline_annotation() {
1523        let mut doc = make_test_doc();
1524        let rect = AnnotRect::new(50.0, 50.0, 400.0, 200.0);
1525        let verts = vec![50.0, 100.0, 200.0, 180.0, 350.0, 60.0, 400.0, 150.0];
1526        let annot_id = AnnotationBuilder::polyline(rect, verts)
1527            .color(1.0, 0.5, 0.0)
1528            .build(&mut doc)
1529            .unwrap();
1530
1531        let annot = doc.get_object(annot_id).unwrap();
1532        if let Object::Dictionary(d) = annot {
1533            assert_eq!(
1534                d.get(b"Subtype").unwrap(),
1535                &Object::Name(b"PolyLine".to_vec())
1536            );
1537        } else {
1538            panic!("Expected dictionary");
1539        }
1540    }
1541
1542    #[test]
1543    fn dashed_line_annotation() {
1544        let mut doc = make_test_doc();
1545        let annot_id = AnnotationBuilder::line(72.0, 400.0, 540.0, 400.0)
1546            .dash(vec![3.0, 2.0])
1547            .build(&mut doc)
1548            .unwrap();
1549
1550        let annot = doc.get_object(annot_id).unwrap();
1551        if let Object::Dictionary(d) = annot {
1552            let bs = d.get(b"BS").unwrap();
1553            if let Object::Dictionary(bs_dict) = bs {
1554                assert_eq!(bs_dict.get(b"S").unwrap(), &Object::Name(b"D".to_vec()));
1555                assert!(bs_dict.get(b"D").is_ok());
1556            } else {
1557                panic!("Expected BS dictionary");
1558            }
1559        } else {
1560            panic!("Expected dictionary");
1561        }
1562    }
1563
1564    // --- Issue #305 tests: Text annotations ---
1565
1566    #[test]
1567    fn free_text_annotation() {
1568        let mut doc = make_test_doc();
1569        let rect = AnnotRect::new(72.0, 700.0, 300.0, 730.0);
1570        let annot_id = AnnotationBuilder::free_text(rect, "Hello World", 14.0)
1571            .alignment(1) // Center
1572            .build(&mut doc)
1573            .unwrap();
1574
1575        let annot = doc.get_object(annot_id).unwrap();
1576        if let Object::Dictionary(d) = annot {
1577            assert_eq!(
1578                d.get(b"Subtype").unwrap(),
1579                &Object::Name(b"FreeText".to_vec())
1580            );
1581            assert!(d.get(b"DA").is_ok());
1582            assert_eq!(d.get(b"Q").unwrap(), &Object::Integer(1));
1583        } else {
1584            panic!("Expected dictionary");
1585        }
1586    }
1587
1588    #[test]
1589    fn sticky_note_annotation() {
1590        let mut doc = make_test_doc();
1591        let rect = AnnotRect::new(500.0, 700.0, 524.0, 724.0);
1592        let annot_id = AnnotationBuilder::sticky_note(rect, TextIcon::Comment)
1593            .contents("This is a comment")
1594            .color(1.0, 1.0, 0.0)
1595            .build(&mut doc)
1596            .unwrap();
1597
1598        let annot = doc.get_object(annot_id).unwrap();
1599        if let Object::Dictionary(d) = annot {
1600            assert_eq!(d.get(b"Subtype").unwrap(), &Object::Name(b"Text".to_vec()));
1601            assert_eq!(d.get(b"Name").unwrap(), &Object::Name(b"Comment".to_vec()));
1602            assert!(d.get(b"Contents").is_ok());
1603        } else {
1604            panic!("Expected dictionary");
1605        }
1606    }
1607
1608    #[test]
1609    fn stamp_annotation() {
1610        let mut doc = make_test_doc();
1611        let rect = AnnotRect::new(72.0, 600.0, 250.0, 650.0);
1612        let annot_id = AnnotationBuilder::stamp(rect, StampName::Approved)
1613            .build(&mut doc)
1614            .unwrap();
1615
1616        let annot = doc.get_object(annot_id).unwrap();
1617        if let Object::Dictionary(d) = annot {
1618            assert_eq!(d.get(b"Subtype").unwrap(), &Object::Name(b"Stamp".to_vec()));
1619            assert_eq!(d.get(b"Name").unwrap(), &Object::Name(b"Approved".to_vec()));
1620        } else {
1621            panic!("Expected dictionary");
1622        }
1623    }
1624
1625    #[test]
1626    fn stamp_custom_name() {
1627        let mut doc = make_test_doc();
1628        let rect = AnnotRect::new(72.0, 500.0, 250.0, 550.0);
1629        let annot_id = AnnotationBuilder::stamp_custom(rect, "ReviewNeeded")
1630            .build(&mut doc)
1631            .unwrap();
1632
1633        let annot = doc.get_object(annot_id).unwrap();
1634        if let Object::Dictionary(d) = annot {
1635            assert_eq!(
1636                d.get(b"Name").unwrap(),
1637                &Object::Name(b"ReviewNeeded".to_vec())
1638            );
1639        } else {
1640            panic!("Expected dictionary");
1641        }
1642    }
1643
1644    #[test]
1645    fn link_uri_annotation() {
1646        let mut doc = make_test_doc();
1647        let rect = AnnotRect::new(72.0, 700.0, 200.0, 712.0);
1648        let annot_id = AnnotationBuilder::link_uri(rect, "https://example.com")
1649            .color(0.0, 0.0, 1.0)
1650            .build(&mut doc)
1651            .unwrap();
1652
1653        let annot = doc.get_object(annot_id).unwrap();
1654        if let Object::Dictionary(d) = annot {
1655            assert_eq!(d.get(b"Subtype").unwrap(), &Object::Name(b"Link".to_vec()));
1656            let action = d.get(b"A").unwrap();
1657            if let Object::Dictionary(a) = action {
1658                assert_eq!(a.get(b"S").unwrap(), &Object::Name(b"URI".to_vec()));
1659            } else {
1660                panic!("Expected action dictionary");
1661            }
1662        } else {
1663            panic!("Expected dictionary");
1664        }
1665    }
1666
1667    #[test]
1668    fn link_destination_annotation() {
1669        let mut doc = make_test_doc();
1670        let rect = AnnotRect::new(72.0, 650.0, 200.0, 662.0);
1671        let annot_id = AnnotationBuilder::link_dest(rect, "chapter1")
1672            .build(&mut doc)
1673            .unwrap();
1674
1675        let annot = doc.get_object(annot_id).unwrap();
1676        if let Object::Dictionary(d) = annot {
1677            assert!(d.get(b"Dest").is_ok());
1678        } else {
1679            panic!("Expected dictionary");
1680        }
1681    }
1682}