Skip to main content

zpdf_document/
ink.rs

1//! Ink annotation builder (ISO 32000-1 §12.5.6.13).
2//!
3//! An ink annotation represents freeform "handwritten" scribbles or graffiti on
4//! a PDF page. This module provides an API to construct ink annotations from a
5//! series of strokes (polylines in page space) and serialize them into the PDF
6//! dictionary + appearance stream format required by the specification.
7
8use zpdf_core::Rect;
9
10/// Builder for ink annotations. Accumulates strokes (each stroke is a polyline
11/// of `(x, y)` points in page space, origin bottom-left) and produces a PDF
12/// annotation dictionary plus its appearance stream.
13#[derive(Debug, Clone)]
14pub struct InkAnnotationBuilder {
15    /// The ink strokes (`/InkList`): an array of paths, where each path is a
16    /// sequence of `(x, y)` points in page space.
17    ink_list: Vec<Vec<(f64, f64)>>,
18    /// Stroke color (DeviceRGB, 0.0–1.0 per component).
19    color: (f64, f64, f64),
20    /// Line width in points.
21    width: f64,
22}
23
24impl InkAnnotationBuilder {
25    /// Create a new builder with default settings (black ink, 1pt width).
26    pub fn new() -> Self {
27        Self {
28            ink_list: Vec::new(),
29            color: (0.0, 0.0, 0.0), // black
30            width: 1.0,
31        }
32    }
33
34    /// Add a stroke (a polyline of `(x, y)` points in page space, origin
35    /// bottom-left, Y+ upward). Each point is in PDF user-space units (1/72 inch).
36    /// At least two points are needed to form a line; single-point or empty
37    /// strokes are silently dropped.
38    pub fn add_stroke(&mut self, points: Vec<(f64, f64)>) {
39        if points.len() >= 2 {
40            self.ink_list.push(points);
41        }
42    }
43
44    /// Set the stroke color (DeviceRGB). Each component is in the range [0.0, 1.0].
45    pub fn set_color(&mut self, r: f64, g: f64, b: f64) {
46        self.color = (r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0));
47    }
48
49    /// Set the line width in points.
50    pub fn set_width(&mut self, w: f64) {
51        self.width = w.max(0.1);
52    }
53
54    /// Compute the bounding rectangle from all strokes, with a small margin to
55    /// account for the line width. Returns `None` if there are no strokes.
56    pub fn compute_rect(&self) -> Option<Rect> {
57        if self.ink_list.is_empty() {
58            return None;
59        }
60        let mut min_x = f64::INFINITY;
61        let mut max_x = f64::NEG_INFINITY;
62        let mut min_y = f64::INFINITY;
63        let mut max_y = f64::NEG_INFINITY;
64
65        for stroke in &self.ink_list {
66            for &(x, y) in stroke {
67                min_x = min_x.min(x);
68                max_x = max_x.max(x);
69                min_y = min_y.min(y);
70                max_y = max_y.max(y);
71            }
72        }
73
74        // Add margin: half the line width on each side, plus a 1pt safety buffer.
75        let margin = self.width / 2.0 + 1.0;
76        Some(Rect {
77            x0: min_x - margin,
78            y0: min_y - margin,
79            x1: max_x + margin,
80            y1: max_y + margin,
81        })
82    }
83
84    /// Build the annotation dictionary and appearance stream. Returns:
85    /// - A PDF dictionary (the annotation object's content, as key-value pairs)
86    /// - The appearance stream bytes (a PDF content stream for `/AP /N`)
87    ///
88    /// Returns `None` if there are no strokes (nothing to serialize).
89    ///
90    /// The caller is responsible for:
91    /// - Wrapping the dict in an indirect object (e.g., `5 0 obj <dict> endobj`)
92    /// - Wrapping the appearance bytes in a stream object with the correct header
93    /// - Assigning object numbers and wiring `/AP /N` to reference the stream
94    pub fn build(&self) -> Option<(InkAnnotDict, Vec<u8>)> {
95        let rect = self.compute_rect()?;
96
97        // The annotation dictionary fields.
98        let dict = InkAnnotDict {
99            rect,
100            ink_list: self.ink_list.clone(),
101            color: self.color,
102            width: self.width,
103        };
104
105        // The appearance stream (PDF content operators).
106        let appearance = self.build_appearance_stream(&rect);
107
108        Some((dict, appearance))
109    }
110
111    /// Generate the PDF content stream for the appearance (`/AP /N`). The stream
112    /// draws each stroke as a path with `m` (moveto) + `l` (lineto) + `S` (stroke).
113    fn build_appearance_stream(&self, _rect: &Rect) -> Vec<u8> {
114        let mut stream = Vec::new();
115        let (r, g, b) = self.color;
116
117        // The appearance XObject has its own coordinate system: the annotation's
118        // `/Rect` becomes the XObject's bounding box (`/BBox`), so we don't need
119        // to offset coordinates — they're already in the right space.
120        //
121        // Content: q <width> w <r g b> RG <strokes> Q
122        stream.extend_from_slice(b"q\n");
123        stream.extend_from_slice(format!("{:.3} w\n", self.width).as_bytes());
124        stream.extend_from_slice(format!("{:.3} {:.3} {:.3} RG\n", r, g, b).as_bytes());
125
126        for stroke in &self.ink_list {
127            if let Some(&(x0, y0)) = stroke.first() {
128                stream.extend_from_slice(format!("{:.2} {:.2} m\n", x0, y0).as_bytes());
129                for &(x, y) in &stroke[1..] {
130                    stream.extend_from_slice(format!("{:.2} {:.2} l\n", x, y).as_bytes());
131                }
132                stream.extend_from_slice(b"S\n");
133            }
134        }
135
136        stream.extend_from_slice(b"Q\n");
137        stream
138    }
139}
140
141impl Default for InkAnnotationBuilder {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147/// The fields of an ink annotation dictionary, ready for serialization.
148#[derive(Debug, Clone)]
149pub struct InkAnnotDict {
150    /// The annotation's bounding rectangle (`/Rect`).
151    pub rect: Rect,
152    /// The ink paths (`/InkList`): an array of arrays of numbers.
153    pub ink_list: Vec<Vec<(f64, f64)>>,
154    /// The stroke color (`/C`), DeviceRGB.
155    pub color: (f64, f64, f64),
156    /// The border width (`/BS /W`).
157    pub width: f64,
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn compute_rect_includes_all_points_with_margin() {
166        let mut builder = InkAnnotationBuilder::new();
167        builder.set_width(2.0);
168        builder.add_stroke(vec![(10.0, 20.0), (30.0, 40.0)]);
169        builder.add_stroke(vec![(5.0, 15.0), (35.0, 45.0)]);
170
171        let rect = builder.compute_rect().expect("rect");
172        // min: (5, 15), max: (35, 45), margin = 2/2 + 1 = 2
173        assert_eq!(rect.x0, 3.0);
174        assert_eq!(rect.y0, 13.0);
175        assert_eq!(rect.x1, 37.0);
176        assert_eq!(rect.y1, 47.0);
177    }
178
179    #[test]
180    fn single_point_strokes_are_dropped() {
181        let mut builder = InkAnnotationBuilder::new();
182        builder.add_stroke(vec![(10.0, 20.0)]); // single point
183        builder.add_stroke(vec![]); // empty
184        assert!(builder.compute_rect().is_none());
185    }
186
187    #[test]
188    fn build_produces_dict_and_appearance() {
189        let mut builder = InkAnnotationBuilder::new();
190        builder.set_color(1.0, 0.0, 0.0); // red
191        builder.set_width(3.0);
192        builder.add_stroke(vec![(100.0, 200.0), (150.0, 250.0)]);
193
194        let (dict, appearance) = builder.build().expect("build");
195        assert_eq!(dict.color, (1.0, 0.0, 0.0));
196        assert_eq!(dict.width, 3.0);
197        assert_eq!(dict.ink_list.len(), 1);
198
199        // The appearance stream must contain the stroke color and path operators.
200        let s = String::from_utf8_lossy(&appearance);
201        assert!(s.contains("1.000 0.000 0.000 RG")); // red stroke color
202        assert!(s.contains("3.000 w")); // line width
203        assert!(s.contains("100.00 200.00 m")); // moveto
204        assert!(s.contains("150.00 250.00 l")); // lineto
205        assert!(s.contains("S")); // stroke
206    }
207
208    #[test]
209    fn color_clamped_to_valid_range() {
210        let mut builder = InkAnnotationBuilder::new();
211        builder.set_color(-0.5, 1.5, 0.5);
212        assert_eq!(builder.color, (0.0, 1.0, 0.5));
213    }
214
215    #[test]
216    fn width_has_minimum() {
217        let mut builder = InkAnnotationBuilder::new();
218        builder.set_width(0.0);
219        assert_eq!(builder.width, 0.1);
220        builder.set_width(-5.0);
221        assert_eq!(builder.width, 0.1);
222    }
223}