Skip to main content

zpdf_writer/
annotate.rs

1//! Annotation authoring: add markup and note annotations to pages.
2//!
3//! Writes spec-conformant annotation dictionaries (ISO 32000-1 §12.5.6)
4//! **without** `/AP` streams — zpdf's own renderer (and Acrobat, per the
5//! spec's "shall generate an appearance" rule) synthesizes the appearance
6//! from the geometry properties via `annot_appearance.rs`, so the authored
7//! annotations render identically on both backends with zero new render
8//! code. `/InkList` annotations (which carry an `/AP`) go through the
9//! existing [`crate::IncrementalWriter::add_ink_annotation_to_page`].
10//!
11//! All coordinates are PDF user space (origin bottom-left, y-up).
12
13use zpdf_core::{ObjectId, PdfDict, PdfName, PdfObject, PdfString, Rect, Result};
14
15use crate::metadata::encode_text_string;
16use crate::{invalid_data, IncrementalWriter};
17
18/// An annotation to author. Colors are DeviceRGB components in `[0, 1]`.
19#[derive(Debug, Clone)]
20pub enum AnnotationSpec {
21    /// Text-markup over one or more oriented quads. Each quad is
22    /// `[x1,y1, x2,y2, x3,y3, x4,y4]` (the `/QuadPoints` order). For
23    /// axis-aligned text, use [`AnnotationSpec::markup_from_rects`].
24    Markup {
25        kind: MarkupKind,
26        quads: Vec<[f64; 8]>,
27        color: (f64, f64, f64),
28        /// Optional comment shown in the annotation's popup.
29        contents: Option<String>,
30    },
31    /// A "sticky note" icon with a comment.
32    Note {
33        /// Icon anchor (lower-left of the icon box; standard size 20×20).
34        x: f64,
35        y: f64,
36        contents: String,
37        color: Option<(f64, f64, f64)>,
38        /// Icon name: Note (default), Comment, Help, Insert, Key, Check, Cross.
39        icon: Option<String>,
40    },
41    /// Free-floating text drawn inside a rectangle.
42    FreeText {
43        rect: Rect,
44        contents: String,
45        /// Font size for the /DA string (default 12).
46        size: Option<f64>,
47        color: Option<(f64, f64, f64)>,
48    },
49    /// Rectangle (Square annotation) with optional interior color.
50    Square {
51        rect: Rect,
52        color: (f64, f64, f64),
53        interior: Option<(f64, f64, f64)>,
54        width: f64,
55    },
56    /// Ellipse (Circle annotation) inscribed in `rect`.
57    Circle {
58        rect: Rect,
59        color: (f64, f64, f64),
60        interior: Option<(f64, f64, f64)>,
61        width: f64,
62    },
63    /// A straight line from `(x1,y1)` to `(x2,y2)`.
64    Line {
65        x1: f64,
66        y1: f64,
67        x2: f64,
68        y2: f64,
69        color: (f64, f64, f64),
70        width: f64,
71    },
72}
73
74/// The four text-markup annotation subtypes.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum MarkupKind {
77    Highlight,
78    Underline,
79    StrikeOut,
80    Squiggly,
81}
82
83impl MarkupKind {
84    fn subtype(self) -> &'static str {
85        match self {
86            MarkupKind::Highlight => "Highlight",
87            MarkupKind::Underline => "Underline",
88            MarkupKind::StrikeOut => "StrikeOut",
89            MarkupKind::Squiggly => "Squiggly",
90        }
91    }
92}
93
94impl AnnotationSpec {
95    /// Build a text-markup spec from axis-aligned rectangles (e.g. search-hit
96    /// rects): each rect becomes one `/QuadPoints` quad.
97    pub fn markup_from_rects(
98        kind: MarkupKind,
99        rects: &[Rect],
100        color: (f64, f64, f64),
101        contents: Option<String>,
102    ) -> Self {
103        let quads = rects
104            .iter()
105            .map(|r| {
106                let r = r.normalize();
107                // QuadPoints order: upper-left, upper-right, lower-left,
108                // lower-right (the de-facto order every viewer expects).
109                [r.x0, r.y1, r.x1, r.y1, r.x0, r.y0, r.x1, r.y0]
110            })
111            .collect();
112        AnnotationSpec::Markup {
113            kind,
114            quads,
115            color,
116            contents,
117        }
118    }
119}
120
121impl IncrementalWriter {
122    /// Append an authored annotation to a page (0-based index). Returns the
123    /// new annotation's object id.
124    ///
125    /// A `/AP /N` appearance stream is baked in (synthesized from the
126    /// annotation geometry via the same generator both render backends use),
127    /// so the annotation is visible even in viewers that never synthesize
128    /// appearances from geometry.
129    pub fn add_annotation(&mut self, page_index: usize, spec: &AnnotationSpec) -> Result<ObjectId> {
130        let mut dict = build_annotation_dict(spec)?;
131        let page_id = self.page_id(page_index)?;
132        self.ensure_object_capacity(2)?;
133
134        // Synthesize the appearance from the finished dict. `None` (e.g. a
135        // degenerate rect) simply leaves the annotation without /AP.
136        let appearance = subtype_name(&dict).and_then(|subtype| {
137            let rect = rect_from_dict(&dict)?;
138            zpdf_document::annot_appearance::generate_annotation_appearance(
139                self.document().file(),
140                &dict,
141                &subtype,
142                rect,
143            )
144        });
145
146        if let Some(ap) = appearance {
147            let mut form = PdfDict::new();
148            form.insert(
149                PdfName::new("Type"),
150                PdfObject::Name(PdfName::new("XObject")),
151            );
152            form.insert(
153                PdfName::new("Subtype"),
154                PdfObject::Name(PdfName::new("Form")),
155            );
156            form.insert(PdfName::new("FormType"), PdfObject::Integer(1));
157            form.insert(
158                PdfName::new("BBox"),
159                PdfObject::Array(vec![
160                    PdfObject::Real(ap.bbox.x0),
161                    PdfObject::Real(ap.bbox.y0),
162                    PdfObject::Real(ap.bbox.x1),
163                    PdfObject::Real(ap.bbox.y1),
164                ]),
165            );
166            let m = ap.matrix;
167            if m != zpdf_core::Matrix::identity() {
168                form.insert(
169                    PdfName::new("Matrix"),
170                    PdfObject::Array(vec![
171                        PdfObject::Real(m.a),
172                        PdfObject::Real(m.b),
173                        PdfObject::Real(m.c),
174                        PdfObject::Real(m.d),
175                        PdfObject::Real(m.e),
176                        PdfObject::Real(m.f),
177                    ]),
178                );
179            }
180            if !ap.resources.0.is_empty() {
181                form.insert(
182                    PdfName::new("Resources"),
183                    PdfObject::Dict(ap.resources.clone()),
184                );
185            }
186            let (ap_num, ap_gen) = self.try_add_stream(&form, &ap.content)?;
187            let mut ap_dict = PdfDict::new();
188            ap_dict.insert(
189                PdfName::new("N"),
190                PdfObject::Ref(ObjectId(ap_num, ap_gen as u16)),
191            );
192            dict.insert(PdfName::new("AP"), PdfObject::Dict(ap_dict));
193        }
194
195        let (num, gen) = self.try_add_object(&PdfObject::Dict(dict))?;
196        let annot_id = ObjectId(num, gen as u16);
197
198        // Append to the page's /Annots (same load-modify-store as ink).
199        let page_obj = self.resolve_current(page_id)?;
200        let mut page_dict = page_obj.as_dict()?.clone();
201        let mut annots = match page_dict.get("Annots") {
202            Some(PdfObject::Ref(r)) => match self.resolve_current(*r) {
203                Ok(obj) => obj.as_array().ok().map(|a| a.to_vec()).unwrap_or_default(),
204                Err(_) => Vec::new(),
205            },
206            Some(PdfObject::Array(arr)) => arr.to_vec(),
207            _ => Vec::new(),
208        };
209        annots.push(PdfObject::Ref(annot_id));
210        page_dict.insert(PdfName::new("Annots"), PdfObject::Array(annots));
211        self.overwrite_object(page_id, PdfObject::Dict(page_dict));
212        Ok(annot_id)
213    }
214}
215
216/// The `/Subtype` name of a finished annotation dict.
217fn subtype_name(dict: &PdfDict) -> Option<String> {
218    match dict.get("Subtype") {
219        Some(PdfObject::Name(n)) => Some(n.as_str().to_string()),
220        _ => None,
221    }
222}
223
224/// The `/Rect` of a finished annotation dict.
225fn rect_from_dict(dict: &PdfDict) -> Option<Rect> {
226    match dict.get("Rect") {
227        Some(PdfObject::Array(a)) if a.len() == 4 => {
228            let mut v = [0.0f64; 4];
229            for (i, obj) in a.iter().enumerate() {
230                v[i] = match obj {
231                    PdfObject::Integer(n) => *n as f64,
232                    PdfObject::Real(f) => *f,
233                    _ => return None,
234                };
235            }
236            Some(Rect::new(v[0], v[1], v[2], v[3]))
237        }
238        _ => None,
239    }
240}
241
242/// The standard note icon size Acrobat uses.
243const NOTE_ICON_SIZE: f64 = 20.0;
244
245fn build_annotation_dict(spec: &AnnotationSpec) -> Result<PdfDict> {
246    let mut dict = PdfDict::new();
247    dict.insert(PdfName::new("Type"), PdfObject::Name(PdfName::new("Annot")));
248
249    match spec {
250        AnnotationSpec::Markup {
251            kind,
252            quads,
253            color,
254            contents,
255        } => {
256            if quads.is_empty() {
257                return Err(invalid_data("markup annotation needs at least one quad").into());
258            }
259            for q in quads {
260                if q.iter().any(|v| !v.is_finite()) {
261                    return Err(invalid_data("quad coordinates must be finite").into());
262                }
263            }
264            dict.insert(
265                PdfName::new("Subtype"),
266                PdfObject::Name(PdfName::new(kind.subtype())),
267            );
268            // /Rect = bounding box of all quads.
269            let (mut x0, mut y0) = (f64::INFINITY, f64::INFINITY);
270            let (mut x1, mut y1) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
271            let mut qp = Vec::with_capacity(quads.len() * 8);
272            for q in quads {
273                for (i, &v) in q.iter().enumerate() {
274                    if i % 2 == 0 {
275                        x0 = x0.min(v);
276                        x1 = x1.max(v);
277                    } else {
278                        y0 = y0.min(v);
279                        y1 = y1.max(v);
280                    }
281                    qp.push(PdfObject::Real(v));
282                }
283            }
284            set_rect(&mut dict, Rect::new(x0, y0, x1, y1));
285            dict.insert(PdfName::new("QuadPoints"), PdfObject::Array(qp));
286            set_color(&mut dict, "C", *color);
287            if let Some(text) = contents {
288                set_contents(&mut dict, text);
289            }
290        }
291        AnnotationSpec::Note {
292            x,
293            y,
294            contents,
295            color,
296            icon,
297        } => {
298            dict.insert(
299                PdfName::new("Subtype"),
300                PdfObject::Name(PdfName::new("Text")),
301            );
302            set_rect(
303                &mut dict,
304                Rect::new(*x, *y, x + NOTE_ICON_SIZE, y + NOTE_ICON_SIZE),
305            );
306            set_contents(&mut dict, contents);
307            if let Some(c) = color {
308                set_color(&mut dict, "C", *c);
309            }
310            if let Some(name) = icon {
311                dict.insert(PdfName::new("Name"), PdfObject::Name(PdfName::new(name)));
312            }
313        }
314        AnnotationSpec::FreeText {
315            rect,
316            contents,
317            size,
318            color,
319        } => {
320            dict.insert(
321                PdfName::new("Subtype"),
322                PdfObject::Name(PdfName::new("FreeText")),
323            );
324            set_rect(&mut dict, *rect);
325            set_contents(&mut dict, contents);
326            // /DA: font + size + fill color (the appearance synthesizer's
327            // FreeText path reads it like a form field default appearance).
328            let (r, g, b) = color.unwrap_or((0.0, 0.0, 0.0));
329            let da = format!("/Helv {} Tf {r:.3} {g:.3} {b:.3} rg", size.unwrap_or(12.0));
330            dict.insert(
331                PdfName::new("DA"),
332                PdfObject::String(PdfString(da.into_bytes())),
333            );
334        }
335        AnnotationSpec::Square {
336            rect,
337            color,
338            interior,
339            width,
340        }
341        | AnnotationSpec::Circle {
342            rect,
343            color,
344            interior,
345            width,
346        } => {
347            let subtype = if matches!(spec, AnnotationSpec::Square { .. }) {
348                "Square"
349            } else {
350                "Circle"
351            };
352            dict.insert(
353                PdfName::new("Subtype"),
354                PdfObject::Name(PdfName::new(subtype)),
355            );
356            set_rect(&mut dict, *rect);
357            set_color(&mut dict, "C", *color);
358            if let Some(ic) = interior {
359                set_color(&mut dict, "IC", *ic);
360            }
361            set_border_width(&mut dict, *width);
362        }
363        AnnotationSpec::Line {
364            x1,
365            y1,
366            x2,
367            y2,
368            color,
369            width,
370        } => {
371            dict.insert(
372                PdfName::new("Subtype"),
373                PdfObject::Name(PdfName::new("Line")),
374            );
375            let pad = width.max(1.0);
376            set_rect(
377                &mut dict,
378                Rect::new(
379                    x1.min(*x2) - pad,
380                    y1.min(*y2) - pad,
381                    x1.max(*x2) + pad,
382                    y1.max(*y2) + pad,
383                ),
384            );
385            dict.insert(
386                PdfName::new("L"),
387                PdfObject::Array(vec![
388                    PdfObject::Real(*x1),
389                    PdfObject::Real(*y1),
390                    PdfObject::Real(*x2),
391                    PdfObject::Real(*y2),
392                ]),
393            );
394            set_color(&mut dict, "C", *color);
395            set_border_width(&mut dict, *width);
396        }
397    }
398    Ok(dict)
399}
400
401fn set_rect(dict: &mut PdfDict, rect: Rect) {
402    let r = rect.normalize();
403    dict.insert(
404        PdfName::new("Rect"),
405        PdfObject::Array(vec![
406            PdfObject::Real(r.x0),
407            PdfObject::Real(r.y0),
408            PdfObject::Real(r.x1),
409            PdfObject::Real(r.y1),
410        ]),
411    );
412}
413
414fn set_color(dict: &mut PdfDict, key: &str, (r, g, b): (f64, f64, f64)) {
415    dict.insert(
416        PdfName::new(key),
417        PdfObject::Array(vec![
418            PdfObject::Real(r.clamp(0.0, 1.0)),
419            PdfObject::Real(g.clamp(0.0, 1.0)),
420            PdfObject::Real(b.clamp(0.0, 1.0)),
421        ]),
422    );
423}
424
425fn set_contents(dict: &mut PdfDict, text: &str) {
426    dict.insert(
427        PdfName::new("Contents"),
428        PdfObject::String(encode_text_string(text)),
429    );
430}
431
432fn set_border_width(dict: &mut PdfDict, width: f64) {
433    let mut bs = PdfDict::new();
434    bs.insert(PdfName::new("W"), PdfObject::Real(width.max(0.0)));
435    dict.insert(PdfName::new("BS"), PdfObject::Dict(bs));
436}