Skip to main content

pdf_annot/builder/
mod.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// Copyright (c) 2026 Innovation Trigger B.V.
7//
8// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
9// the PDFluent Commercial Licence. See the LICENSE file in this repository --
10// that file travels with the copy you received, which a URL does not.
11
12mod appearance;
13mod page_attach;
14pub mod types;
15
16#[cfg(feature = "write")]
17use lopdf::{dictionary, Document, Object, ObjectId, Stream};
18
19#[cfg(feature = "write")]
20use crate::appearance_writer::{AppearanceColor, AppearanceStreamBuilder};
21#[cfg(feature = "write")]
22use crate::error::AnnotBuildError;
23
24// Re-export the unified StampName from the stamp module so that
25// `pdf_annot::builder::StampName` keeps working for existing call sites.
26#[cfg(feature = "write")]
27pub use crate::StampName;
28
29#[cfg(feature = "write")]
30pub use page_attach::add_annotation_to_page;
31
32#[cfg(feature = "write")]
33pub use types::*;
34
35/// Type alias for a custom appearance builder closure.
36#[cfg(feature = "write")]
37type AppearanceFn = Box<dyn FnOnce(&mut AppearanceStreamBuilder)>;
38
39/// Builder for creating a PDF annotation and adding it to a document.
40///
41/// # Example
42/// ```no_run
43/// use pdf_annot::builder::{AnnotationBuilder, AnnotSubtype, AnnotRect};
44///
45/// let mut doc = lopdf::Document::with_version("1.7");
46/// // ... add pages ...
47/// let annot_id = AnnotationBuilder::new(AnnotSubtype::Square, AnnotRect::new(100.0, 200.0, 300.0, 400.0))
48///     .color(1.0, 0.0, 0.0)
49///     .border_width(2.0)
50///     .contents("A red square")
51///     .build(&mut doc)
52///     .unwrap();
53/// ```
54#[cfg(feature = "write")]
55pub struct AnnotationBuilder {
56    subtype: AnnotSubtype,
57    rect: AnnotRect,
58    color: Option<AppearanceColor>,
59    interior_color: Option<AppearanceColor>,
60    opacity: Option<f64>,
61    border_width: f64,
62    contents: Option<String>,
63    flags: u32,
64    /// QuadPoints for markup annotations (Highlight, Underline, StrikeOut, Squiggly).
65    quad_points: Option<Vec<f64>>,
66    /// Line endpoints /L [x1 y1 x2 y2] for Line annotations.
67    line_endpoints: Option<[f64; 4]>,
68    /// Line endings /LE for Line annotations.
69    line_endings: Option<[LineEnding; 2]>,
70    /// InkList for Ink annotations: list of stroke paths.
71    ink_list: Option<Vec<Vec<f64>>>,
72    /// Vertices for Polygon/PolyLine annotations.
73    vertices: Option<Vec<f64>>,
74    /// Dash pattern for stroked annotations.
75    dash_pattern: Option<Vec<f64>>,
76    /// Default appearance string (/DA) for FreeText annotations.
77    default_appearance_str: Option<String>,
78    /// Text alignment (/Q) for FreeText: 0=left, 1=center, 2=right.
79    text_alignment: Option<i64>,
80    /// Icon name (/Name) for Text (sticky note) and Stamp annotations.
81    icon_name: Option<String>,
82    /// URI action (/A) for Link annotations.
83    uri_action: Option<String>,
84    /// Named destination (/Dest) for Link annotations.
85    destination: Option<String>,
86    /// Custom appearance builder function. If None, a default appearance is generated.
87    custom_appearance: Option<AppearanceFn>,
88}
89
90#[cfg(feature = "write")]
91impl AnnotationBuilder {
92    /// Create a new annotation builder for the given subtype and rectangle.
93    pub fn new(subtype: AnnotSubtype, rect: AnnotRect) -> Self {
94        Self {
95            subtype,
96            rect,
97            color: None,
98            interior_color: None,
99            opacity: None,
100            border_width: 1.0,
101            contents: None,
102            flags: 4, // Print flag set by default
103            quad_points: None,
104            line_endpoints: None,
105            line_endings: None,
106            ink_list: None,
107            vertices: None,
108            dash_pattern: None,
109            default_appearance_str: None,
110            text_alignment: None,
111            icon_name: None,
112            uri_action: None,
113            destination: None,
114            custom_appearance: None,
115        }
116    }
117
118    /// Create a FreeText annotation with the given text and font size.
119    pub fn free_text(rect: AnnotRect, text: &str, font_size: f64) -> Self {
120        let da = format!("/Helv {font_size} Tf 0 g");
121        let mut b = Self::new(AnnotSubtype::FreeText, rect).contents(text);
122        b.default_appearance_str = Some(da);
123        b
124    }
125
126    /// Create a Text (sticky note) annotation.
127    pub fn sticky_note(rect: AnnotRect, icon: TextIcon) -> Self {
128        let mut b = Self::new(AnnotSubtype::Text, rect);
129        b.icon_name = Some(icon.as_str().to_string());
130        b
131    }
132
133    /// Create a Stamp annotation with a standard stamp name.
134    pub fn stamp(rect: AnnotRect, name: StampName) -> Self {
135        let mut b = Self::new(AnnotSubtype::Stamp, rect);
136        b.icon_name = Some(name.to_pdf_name().to_string());
137        b
138    }
139
140    /// Create a Stamp annotation with a custom name.
141    pub fn stamp_custom(rect: AnnotRect, name: &str) -> Self {
142        let mut b = Self::new(AnnotSubtype::Stamp, rect);
143        b.icon_name = Some(name.to_string());
144        b
145    }
146
147    /// Create a Link annotation with a URI action.
148    pub fn link_uri(rect: AnnotRect, uri: &str) -> Self {
149        let mut b = Self::new(AnnotSubtype::Link, rect);
150        b.uri_action = Some(uri.to_string());
151        b.border_width = 0.0; // Links typically have no border.
152        b
153    }
154
155    /// Create a Link annotation with a named destination.
156    pub fn link_dest(rect: AnnotRect, dest: &str) -> Self {
157        let mut b = Self::new(AnnotSubtype::Link, rect);
158        b.destination = Some(dest.to_string());
159        b.border_width = 0.0;
160        b
161    }
162
163    /// Create a Square annotation.
164    pub fn square(rect: AnnotRect) -> Self {
165        Self::new(AnnotSubtype::Square, rect)
166    }
167
168    /// Create a Circle annotation.
169    pub fn circle(rect: AnnotRect) -> Self {
170        Self::new(AnnotSubtype::Circle, rect)
171    }
172
173    /// Create a Line annotation between two points.
174    ///
175    /// Automatically pads the bounding rect so it is never zero-area.
176    pub fn line(x1: f64, y1: f64, x2: f64, y2: f64) -> Self {
177        let pad = 1.0; // Minimum 1pt padding to prevent zero-area rect.
178        let mut min_x = x1.min(x2);
179        let mut min_y = y1.min(y2);
180        let mut max_x = x1.max(x2);
181        let mut max_y = y1.max(y2);
182        if (max_x - min_x).abs() < f64::EPSILON {
183            min_x -= pad;
184            max_x += pad;
185        }
186        if (max_y - min_y).abs() < f64::EPSILON {
187            min_y -= pad;
188            max_y += pad;
189        }
190        let rect = AnnotRect::new(min_x, min_y, max_x, max_y);
191        let mut b = Self::new(AnnotSubtype::Line, rect);
192        b.line_endpoints = Some([x1, y1, x2, y2]);
193        b
194    }
195
196    /// Create an Ink annotation from stroke paths.
197    pub fn ink(rect: AnnotRect, strokes: Vec<Vec<f64>>) -> Self {
198        let mut b = Self::new(AnnotSubtype::Ink, rect);
199        b.ink_list = Some(strokes);
200        b
201    }
202
203    /// Create a Polygon annotation from vertices.
204    pub fn polygon(rect: AnnotRect, vertices: Vec<f64>) -> Self {
205        let mut b = Self::new(AnnotSubtype::Polygon, rect);
206        b.vertices = Some(vertices);
207        b
208    }
209
210    /// Create a PolyLine annotation from vertices.
211    pub fn polyline(rect: AnnotRect, vertices: Vec<f64>) -> Self {
212        let mut b = Self::new(AnnotSubtype::PolyLine, rect);
213        b.vertices = Some(vertices);
214        b
215    }
216
217    /// Create a Highlight markup annotation.
218    pub fn highlight(rect: AnnotRect) -> Self {
219        Self::new(AnnotSubtype::Highlight, rect)
220            .color(1.0, 1.0, 0.0) // Yellow
221            .opacity(0.4)
222    }
223
224    /// Create an Underline markup annotation.
225    pub fn underline(rect: AnnotRect) -> Self {
226        Self::new(AnnotSubtype::Underline, rect).color(0.0, 0.0, 1.0)
227    }
228
229    /// Create a StrikeOut markup annotation.
230    pub fn strikeout(rect: AnnotRect) -> Self {
231        Self::new(AnnotSubtype::StrikeOut, rect).color(1.0, 0.0, 0.0)
232    }
233
234    /// Create a Squiggly markup annotation.
235    pub fn squiggly(rect: AnnotRect) -> Self {
236        Self::new(AnnotSubtype::Squiggly, rect).color(0.0, 0.8, 0.0)
237    }
238
239    /// Set the annotation color (RGB, 0.0–1.0).
240    pub fn color(mut self, r: f64, g: f64, b: f64) -> Self {
241        self.color = Some(AppearanceColor::new(r, g, b));
242        self
243    }
244
245    /// Set the interior (fill) color for annotations that support it.
246    pub fn interior_color(mut self, r: f64, g: f64, b: f64) -> Self {
247        self.interior_color = Some(AppearanceColor::new(r, g, b));
248        self
249    }
250
251    /// Set the opacity (CA/ca, 0.0–1.0).
252    pub fn opacity(mut self, alpha: f64) -> Self {
253        self.opacity = Some(alpha.clamp(0.0, 1.0));
254        self
255    }
256
257    /// Set the border/stroke width.
258    pub fn border_width(mut self, width: f64) -> Self {
259        self.border_width = width;
260        self
261    }
262
263    /// Set the /Contents text.
264    pub fn contents(mut self, text: impl Into<String>) -> Self {
265        self.contents = Some(text.into());
266        self
267    }
268
269    /// Set the annotation flags (raw u32).
270    pub fn flags(mut self, flags: u32) -> Self {
271        self.flags = flags;
272        self
273    }
274
275    /// Set text alignment for FreeText annotations (0=left, 1=center, 2=right).
276    pub fn alignment(mut self, q: i64) -> Self {
277        self.text_alignment = Some(q);
278        self
279    }
280
281    /// Set line endings for Line annotations.
282    pub fn line_endings(mut self, start: LineEnding, end: LineEnding) -> Self {
283        self.line_endings = Some([start, end]);
284        self
285    }
286
287    /// Set a dash pattern for stroked annotations.
288    pub fn dash(mut self, pattern: Vec<f64>) -> Self {
289        self.dash_pattern = Some(pattern);
290        self
291    }
292
293    /// Set QuadPoints for text markup annotations.
294    ///
295    /// Each quadrilateral is defined by 8 values in page coordinates:
296    /// `[x1,y1, x2,y2, x3,y3, x4,y4]` where the points define the
297    /// corners of the marked text region. Multiple quads can be
298    /// concatenated for multi-line selections.
299    pub fn quad_points(mut self, points: Vec<f64>) -> Self {
300        self.quad_points = Some(points);
301        self
302    }
303
304    /// Set QuadPoints from a simple rectangle (single quad).
305    pub fn quad_points_from_rect(self, rect: &AnnotRect) -> Self {
306        // PDF QuadPoints order: top-left, top-right, bottom-left, bottom-right
307        self.quad_points(vec![
308            rect.x0, rect.y1, // top-left
309            rect.x1, rect.y1, // top-right
310            rect.x0, rect.y0, // bottom-left
311            rect.x1, rect.y0, // bottom-right
312        ])
313    }
314
315    /// Provide a custom appearance builder closure.
316    pub fn appearance(mut self, f: impl FnOnce(&mut AppearanceStreamBuilder) + 'static) -> Self {
317        self.custom_appearance = Some(Box::new(f));
318        self
319    }
320
321    /// Build the annotation, add it to the document, and return the annotation object ID.
322    ///
323    /// This creates the annotation dictionary, generates the appearance stream,
324    /// and adds both as objects to the document. The annotation is NOT automatically
325    /// added to any page's /Annots array — use `add_to_page` for that.
326    pub fn build(mut self, doc: &mut Document) -> Result<ObjectId, AnnotBuildError> {
327        let w = self.rect.width();
328        let h = self.rect.height();
329        if w < f64::EPSILON || h < f64::EPSILON {
330            return Err(AnnotBuildError::InvalidRect);
331        }
332
333        // Extract custom appearance before building (avoids borrow issues).
334        let custom_appearance = self.custom_appearance.take();
335
336        // Build appearance stream.
337        let ap_stream_id = self.build_appearance(doc, w, h, custom_appearance)?;
338
339        // Build the annotation dictionary.
340        let mut annot_dict = dictionary! {
341            "Type" => "Annot",
342            "Subtype" => Object::Name(self.subtype.as_str().as_bytes().to_vec()),
343            "Rect" => self.rect.as_array(),
344            "F" => Object::Integer(self.flags as i64),
345        };
346
347        // Color (/C).
348        if let Some(ref c) = self.color {
349            annot_dict.set(
350                "C",
351                Object::Array(vec![
352                    Object::Real(c.r as f32),
353                    Object::Real(c.g as f32),
354                    Object::Real(c.b as f32),
355                ]),
356            );
357        }
358
359        // Interior color (/IC) — for Square, Circle.
360        if let Some(ref ic) = self.interior_color {
361            annot_dict.set(
362                "IC",
363                Object::Array(vec![
364                    Object::Real(ic.r as f32),
365                    Object::Real(ic.g as f32),
366                    Object::Real(ic.b as f32),
367                ]),
368            );
369        }
370
371        // Opacity.
372        if let Some(alpha) = self.opacity {
373            annot_dict.set("CA", Object::Real(alpha as f32));
374        }
375
376        // Contents.
377        if let Some(ref text) = self.contents {
378            annot_dict.set(
379                "Contents",
380                Object::String(text.as_bytes().to_vec(), lopdf::StringFormat::Literal),
381            );
382        }
383
384        // QuadPoints for markup annotations.
385        if let Some(ref qp) = self.quad_points {
386            let arr: Vec<Object> = qp.iter().map(|&v| Object::Real(v as f32)).collect();
387            annot_dict.set("QuadPoints", Object::Array(arr));
388        }
389
390        // Line endpoints (/L) for Line annotations.
391        if let Some(ref l) = self.line_endpoints {
392            annot_dict.set(
393                "L",
394                Object::Array(vec![
395                    Object::Real(l[0] as f32),
396                    Object::Real(l[1] as f32),
397                    Object::Real(l[2] as f32),
398                    Object::Real(l[3] as f32),
399                ]),
400            );
401        }
402
403        // Line endings (/LE).
404        if let Some(ref le) = self.line_endings {
405            annot_dict.set(
406                "LE",
407                Object::Array(vec![
408                    Object::Name(le[0].as_str().as_bytes().to_vec()),
409                    Object::Name(le[1].as_str().as_bytes().to_vec()),
410                ]),
411            );
412        }
413
414        // InkList for Ink annotations.
415        if let Some(ref ink) = self.ink_list {
416            let ink_arr: Vec<Object> = ink
417                .iter()
418                .map(|stroke| {
419                    Object::Array(stroke.iter().map(|&v| Object::Real(v as f32)).collect())
420                })
421                .collect();
422            annot_dict.set("InkList", Object::Array(ink_arr));
423        }
424
425        // Vertices for Polygon/PolyLine annotations.
426        if let Some(ref verts) = self.vertices {
427            let arr: Vec<Object> = verts.iter().map(|&v| Object::Real(v as f32)).collect();
428            annot_dict.set("Vertices", Object::Array(arr));
429        }
430
431        // Border style.
432        let has_dash = self.dash_pattern.is_some();
433        if (self.border_width - 1.0).abs() > f64::EPSILON || has_dash {
434            let mut bs = dictionary! {
435                "W" => Object::Real(self.border_width as f32),
436            };
437            if has_dash {
438                bs.set("S", Object::Name(b"D".to_vec()));
439                let d_arr: Vec<Object> = self
440                    .dash_pattern
441                    .as_ref()
442                    .expect("guarded by has_dash which checks is_some()")
443                    .iter()
444                    .map(|&v| Object::Real(v as f32))
445                    .collect();
446                bs.set("D", Object::Array(d_arr));
447            } else {
448                bs.set("S", Object::Name(b"S".to_vec()));
449            }
450            annot_dict.set("BS", Object::Dictionary(bs));
451        }
452
453        // Default appearance string (/DA) for FreeText.
454        if let Some(ref da) = self.default_appearance_str {
455            annot_dict.set(
456                "DA",
457                Object::String(da.as_bytes().to_vec(), lopdf::StringFormat::Literal),
458            );
459        }
460
461        // Text alignment (/Q) for FreeText.
462        if let Some(q) = self.text_alignment {
463            annot_dict.set("Q", Object::Integer(q));
464        }
465
466        // Icon name (/Name) for Text, Stamp.
467        if let Some(ref name) = self.icon_name {
468            annot_dict.set("Name", Object::Name(name.as_bytes().to_vec()));
469        }
470
471        // URI action (/A) for Link.
472        if let Some(ref uri) = self.uri_action {
473            let action = dictionary! {
474                "S" => "URI",
475                "URI" => Object::String(uri.as_bytes().to_vec(), lopdf::StringFormat::Literal),
476            };
477            annot_dict.set("A", Object::Dictionary(action));
478        }
479
480        // Named destination (/Dest) for Link.
481        if let Some(ref dest) = self.destination {
482            annot_dict.set(
483                "Dest",
484                Object::String(dest.as_bytes().to_vec(), lopdf::StringFormat::Literal),
485            );
486        }
487
488        // Normal appearance.
489        let ap = dictionary! {
490            "N" => Object::Reference(ap_stream_id),
491        };
492        annot_dict.set("AP", Object::Dictionary(ap));
493
494        Ok(doc.add_object(Object::Dictionary(annot_dict)))
495    }
496
497    /// Build appearance stream and add it as a Form XObject to the document.
498    fn build_appearance(
499        &self,
500        doc: &mut Document,
501        w: f64,
502        h: f64,
503        custom_appearance: Option<AppearanceFn>,
504    ) -> Result<ObjectId, AnnotBuildError> {
505        let mut builder = AppearanceStreamBuilder::new(w, h);
506
507        if let Some(custom) = custom_appearance {
508            custom(&mut builder);
509        } else {
510            self.default_appearance(&mut builder, w, h);
511        }
512
513        let content_bytes = builder
514            .encode()
515            .map_err(AnnotBuildError::AppearanceEncode)?;
516
517        let mut stream_dict = dictionary! {
518            "Type" => "XObject",
519            "Subtype" => "Form",
520            "BBox" => Object::Array(vec![
521                Object::Real(0.0),
522                Object::Real(0.0),
523                Object::Real(w as f32),
524                Object::Real(h as f32),
525            ]),
526        };
527
528        // Build resources for the form XObject.
529        let needs_multiply = matches!(self.subtype, AnnotSubtype::Highlight);
530        let needs_gs = self.opacity.is_some() || needs_multiply;
531        let needs_font = matches!(self.subtype, AnnotSubtype::FreeText | AnnotSubtype::Stamp);
532
533        if needs_gs || needs_font {
534            let mut resources = lopdf::Dictionary::new();
535
536            if needs_gs {
537                let mut gs_dict = dictionary! {
538                    "Type" => "ExtGState",
539                };
540                if let Some(alpha) = self.opacity {
541                    gs_dict.set("ca", Object::Real(alpha as f32));
542                    gs_dict.set("CA", Object::Real(alpha as f32));
543                }
544                if needs_multiply {
545                    gs_dict.set("BM", Object::Name(b"Multiply".to_vec()));
546                }
547                let gs_id = doc.add_object(Object::Dictionary(gs_dict));
548                let mut gs_res = lopdf::Dictionary::new();
549                gs_res.set("GS0", Object::Reference(gs_id));
550                resources.set("ExtGState", Object::Dictionary(gs_res));
551            }
552
553            if needs_font {
554                let font_dict = dictionary! {
555                    "Type" => "Font",
556                    "Subtype" => "Type1",
557                    "BaseFont" => "Helvetica",
558                };
559                let font_id = doc.add_object(Object::Dictionary(font_dict));
560                let mut font_res = lopdf::Dictionary::new();
561                font_res.set("Helv", Object::Reference(font_id));
562                resources.set("Font", Object::Dictionary(font_res));
563            }
564
565            stream_dict.set("Resources", Object::Dictionary(resources));
566        }
567
568        let stream = Stream::new(stream_dict, content_bytes);
569        Ok(doc.add_object(Object::Stream(stream)))
570    }
571}
572
573#[cfg(all(test, feature = "write"))]
574mod tests;