Skip to main content

oxidize_pdf/graphics/
form_xobject.rs

1//! Form XObjects for reusable graphics content
2//!
3//! Implements ISO 32000-1 Section 8.10 (Form XObjects)
4//! Form XObjects are self-contained descriptions of graphics objects that can be
5//! painted multiple times on different pages or at different locations.
6
7use crate::error::Result;
8use crate::geometry::Rectangle;
9use crate::objects::{Dictionary, Object, ObjectId, Stream};
10use std::collections::HashMap;
11
12/// Form XObject - reusable graphics content
13#[derive(Debug, Clone)]
14pub struct FormXObject {
15    /// Bounding box of the form
16    pub bbox: Rectangle,
17    /// Optional transformation matrix
18    pub matrix: Option<[f64; 6]>,
19    /// Resources used by the form
20    pub resources: Dictionary,
21    /// Graphics operations content
22    pub content: Vec<u8>,
23    /// Optional group attributes for transparency
24    pub group: Option<TransparencyGroup>,
25    /// Optional reference to external XObject
26    pub reference: Option<ObjectId>,
27    /// Metadata
28    pub metadata: Option<Dictionary>,
29}
30
31/// Transparency group attributes
32#[derive(Debug, Clone)]
33pub struct TransparencyGroup {
34    /// Color space for group
35    pub color_space: String,
36    /// Whether group is isolated
37    pub isolated: bool,
38    /// Whether group is knockout
39    pub knockout: bool,
40}
41
42impl Default for TransparencyGroup {
43    fn default() -> Self {
44        Self {
45            color_space: "DeviceRGB".to_string(),
46            isolated: false,
47            knockout: false,
48        }
49    }
50}
51
52impl FormXObject {
53    /// Create a new form XObject
54    pub fn new(bbox: Rectangle) -> Self {
55        Self {
56            bbox,
57            matrix: None,
58            resources: Dictionary::new(),
59            content: Vec::new(),
60            group: None,
61            reference: None,
62            metadata: None,
63        }
64    }
65
66    /// Set transformation matrix
67    pub fn with_matrix(mut self, matrix: [f64; 6]) -> Self {
68        self.matrix = Some(matrix);
69        self
70    }
71
72    /// Set resources
73    pub fn with_resources(mut self, resources: Dictionary) -> Self {
74        self.resources = resources;
75        self
76    }
77
78    /// Set content stream
79    pub fn with_content(mut self, content: Vec<u8>) -> Self {
80        self.content = content;
81        self
82    }
83
84    /// Add transparency group
85    pub fn with_transparency_group(mut self, group: TransparencyGroup) -> Self {
86        self.group = Some(group);
87        self
88    }
89
90    /// Set metadata
91    pub fn with_metadata(mut self, metadata: Dictionary) -> Self {
92        self.metadata = Some(metadata);
93        self
94    }
95
96    /// Create a form XObject from graphics operations
97    pub fn from_graphics_ops(bbox: Rectangle, ops: &str) -> Self {
98        Self {
99            bbox,
100            matrix: None,
101            resources: Dictionary::new(),
102            content: ops.as_bytes().to_vec(),
103            group: None,
104            reference: None,
105            metadata: None,
106        }
107    }
108
109    /// Convert to PDF stream object
110    pub fn to_stream(&self) -> Result<Stream> {
111        let mut dict = Dictionary::new();
112
113        // Required entries
114        dict.set("Type", Object::Name("XObject".to_string()));
115        dict.set("Subtype", Object::Name("Form".to_string()));
116
117        // BBox is required
118        dict.set(
119            "BBox",
120            Object::Array(vec![
121                Object::Real(self.bbox.lower_left.x),
122                Object::Real(self.bbox.lower_left.y),
123                Object::Real(self.bbox.upper_right.x),
124                Object::Real(self.bbox.upper_right.y),
125            ]),
126        );
127
128        // Optional matrix
129        if let Some(matrix) = &self.matrix {
130            dict.set(
131                "Matrix",
132                Object::Array(matrix.iter().map(|&v| Object::Real(v)).collect()),
133            );
134        }
135
136        // Resources
137        dict.set("Resources", Object::Dictionary(self.resources.clone()));
138
139        // Transparency group if present
140        if let Some(group) = &self.group {
141            let mut group_dict = Dictionary::new();
142            group_dict.set("Type", Object::Name("Group".to_string()));
143            group_dict.set("S", Object::Name("Transparency".to_string()));
144            group_dict.set("CS", Object::Name(group.color_space.clone()));
145
146            if group.isolated {
147                group_dict.set("I", Object::Boolean(true));
148            }
149            if group.knockout {
150                group_dict.set("K", Object::Boolean(true));
151            }
152
153            dict.set("Group", Object::Dictionary(group_dict));
154        }
155
156        // Optional metadata
157        if let Some(metadata) = &self.metadata {
158            dict.set("Metadata", Object::Dictionary(metadata.clone()));
159        }
160
161        Ok(Stream::with_dictionary(dict, self.content.clone()))
162    }
163
164    /// Get the bounding box
165    pub fn get_bbox(&self) -> &Rectangle {
166        &self.bbox
167    }
168
169    /// Check if form has transparency
170    pub fn has_transparency(&self) -> bool {
171        self.group.is_some()
172    }
173}
174
175/// Builder for creating form XObjects with graphics operations
176pub struct FormXObjectBuilder {
177    bbox: Rectangle,
178    matrix: Option<[f64; 6]>,
179    resources: Dictionary,
180    operations: Vec<String>,
181    group: Option<TransparencyGroup>,
182}
183
184impl FormXObjectBuilder {
185    /// Create a new builder
186    pub fn new(bbox: Rectangle) -> Self {
187        Self {
188            bbox,
189            matrix: None,
190            resources: Dictionary::new(),
191            operations: Vec::new(),
192            group: None,
193        }
194    }
195
196    /// Set transformation matrix
197    pub fn matrix(mut self, matrix: [f64; 6]) -> Self {
198        self.matrix = Some(matrix);
199        self
200    }
201
202    /// Add a graphics operation
203    pub fn add_operation(mut self, op: impl Into<String>) -> Self {
204        self.operations.push(op.into());
205        self
206    }
207
208    /// Draw a rectangle
209    pub fn rectangle(mut self, x: f64, y: f64, width: f64, height: f64) -> Self {
210        self.operations
211            .push(format!("{} {} {} {} re", x, y, width, height));
212        self
213    }
214
215    /// Move to point
216    pub fn move_to(mut self, x: f64, y: f64) -> Self {
217        self.operations.push(format!("{} {} m", x, y));
218        self
219    }
220
221    /// Line to point
222    pub fn line_to(mut self, x: f64, y: f64) -> Self {
223        self.operations.push(format!("{} {} l", x, y));
224        self
225    }
226
227    /// Set fill color (RGB) — routed through the shared NaN-sanitising
228    /// helper (issues #220 + #221) so non-finite inputs cannot produce
229    /// `NaN`/`inf` tokens that ISO 32000-1 §7.3.3 rejects.
230    pub fn fill_color(mut self, r: f64, g: f64, b: f64) -> Self {
231        self.operations.push(crate::graphics::color::fill_color_op(
232            crate::graphics::Color::Rgb(r, g, b),
233        ));
234        self
235    }
236
237    /// Set stroke color (RGB) — sanitised via shared helper.
238    pub fn stroke_color(mut self, r: f64, g: f64, b: f64) -> Self {
239        self.operations
240            .push(crate::graphics::color::stroke_color_op(
241                crate::graphics::Color::Rgb(r, g, b),
242            ));
243        self
244    }
245
246    /// Fill path
247    pub fn fill(mut self) -> Self {
248        self.operations.push("f".to_string());
249        self
250    }
251
252    /// Stroke path
253    pub fn stroke(mut self) -> Self {
254        self.operations.push("S".to_string());
255        self
256    }
257
258    /// Fill and stroke path
259    pub fn fill_stroke(mut self) -> Self {
260        self.operations.push("B".to_string());
261        self
262    }
263
264    /// Save graphics state
265    pub fn save_state(mut self) -> Self {
266        self.operations.push("q".to_string());
267        self
268    }
269
270    /// Restore graphics state
271    pub fn restore_state(mut self) -> Self {
272        self.operations.push("Q".to_string());
273        self
274    }
275
276    /// Add transparency group
277    pub fn transparency_group(mut self, isolated: bool, knockout: bool) -> Self {
278        self.group = Some(TransparencyGroup {
279            color_space: "DeviceRGB".to_string(),
280            isolated,
281            knockout,
282        });
283        self
284    }
285
286    /// Build the form XObject
287    pub fn build(self) -> FormXObject {
288        let content = self.operations.join("\n").into_bytes();
289
290        FormXObject {
291            bbox: self.bbox,
292            matrix: self.matrix,
293            resources: self.resources,
294            content,
295            group: self.group,
296            reference: None,
297            metadata: None,
298        }
299    }
300}
301
302/// Template form XObject for common shapes
303pub struct FormTemplates;
304
305impl FormTemplates {
306    /// Create a checkmark form
307    pub fn checkmark(size: f64) -> FormXObject {
308        let bbox = Rectangle::from_position_and_size(0.0, 0.0, size, size);
309
310        FormXObjectBuilder::new(bbox)
311            .stroke_color(0.0, 0.5, 0.0)
312            .move_to(size * 0.2, size * 0.5)
313            .line_to(size * 0.4, size * 0.3)
314            .line_to(size * 0.8, size * 0.7)
315            .stroke()
316            .build()
317    }
318
319    /// Create a cross/X form
320    pub fn cross(size: f64) -> FormXObject {
321        let bbox = Rectangle::from_position_and_size(0.0, 0.0, size, size);
322
323        FormXObjectBuilder::new(bbox)
324            .stroke_color(0.8, 0.0, 0.0)
325            .move_to(size * 0.2, size * 0.2)
326            .line_to(size * 0.8, size * 0.8)
327            .move_to(size * 0.2, size * 0.8)
328            .line_to(size * 0.8, size * 0.2)
329            .stroke()
330            .build()
331    }
332
333    /// Create a circle form
334    pub fn circle(radius: f64, filled: bool) -> FormXObject {
335        let size = radius * 2.0;
336        let bbox = Rectangle::from_position_and_size(0.0, 0.0, size, size);
337
338        // Approximate circle with Bézier curves
339        let k = 0.5522847498; // Magic constant for circle approximation
340        let cp = radius * k; // Control point offset
341
342        let mut builder = FormXObjectBuilder::new(bbox);
343
344        if filled {
345            builder = builder.fill_color(0.0, 0.0, 1.0);
346        } else {
347            builder = builder.stroke_color(0.0, 0.0, 1.0);
348        }
349
350        // Move to right point
351        builder = builder
352            .move_to(size, radius)
353            .add_operation(format!(
354                "{} {} {} {} {} {} c", // Top right quadrant
355                size,
356                radius + cp,
357                radius + cp,
358                size,
359                radius,
360                size
361            ))
362            .add_operation(format!(
363                "{} {} {} {} {} {} c", // Top left quadrant
364                radius - cp,
365                size,
366                0.0,
367                radius + cp,
368                0.0,
369                radius
370            ))
371            .add_operation(format!(
372                "{} {} {} {} {} {} c", // Bottom left quadrant
373                0.0,
374                radius - cp,
375                radius - cp,
376                0.0,
377                radius,
378                0.0
379            ))
380            .add_operation(format!(
381                "{} {} {} {} {} {} c", // Bottom right quadrant
382                radius + cp,
383                0.0,
384                size,
385                radius - cp,
386                size,
387                radius
388            ));
389
390        if filled {
391            builder.fill()
392        } else {
393            builder.stroke()
394        }
395        .build()
396    }
397
398    /// Create a star form
399    pub fn star(size: f64, points: usize) -> FormXObject {
400        let bbox = Rectangle::from_position_and_size(0.0, 0.0, size, size);
401        let center = size / 2.0;
402        let outer_radius = size / 2.0 * 0.9;
403        let inner_radius = outer_radius * 0.4;
404
405        let mut builder = FormXObjectBuilder::new(bbox).fill_color(1.0, 0.8, 0.0);
406
407        let angle_step = std::f64::consts::PI * 2.0 / (points * 2) as f64;
408
409        for i in 0..(points * 2) {
410            let angle = i as f64 * angle_step - std::f64::consts::PI / 2.0;
411            let radius = if i % 2 == 0 {
412                outer_radius
413            } else {
414                inner_radius
415            };
416            let x = center + radius * angle.cos();
417            let y = center + radius * angle.sin();
418
419            if i == 0 {
420                builder = builder.move_to(x, y);
421            } else {
422                builder = builder.line_to(x, y);
423            }
424        }
425
426        builder.add_operation("h".to_string()).fill().build()
427    }
428
429    /// Create a logo placeholder form
430    pub fn logo_placeholder(width: f64, height: f64) -> FormXObject {
431        let bbox = Rectangle::from_position_and_size(0.0, 0.0, width, height);
432
433        FormXObjectBuilder::new(bbox)
434            .save_state()
435            // Border
436            .stroke_color(0.5, 0.5, 0.5)
437            .rectangle(1.0, 1.0, width - 2.0, height - 2.0)
438            .stroke()
439            // Diagonal lines
440            .move_to(0.0, 0.0)
441            .line_to(width, height)
442            .move_to(0.0, height)
443            .line_to(width, 0.0)
444            .stroke()
445            .restore_state()
446            .build()
447    }
448}
449
450/// Manager for form XObjects in a document
451#[derive(Debug, Clone)]
452pub struct FormXObjectManager {
453    forms: HashMap<String, FormXObject>,
454    next_id: usize,
455}
456
457impl Default for FormXObjectManager {
458    fn default() -> Self {
459        Self {
460            forms: HashMap::new(),
461            next_id: 1,
462        }
463    }
464}
465
466impl FormXObjectManager {
467    /// Create a new manager
468    pub fn new() -> Self {
469        Self::default()
470    }
471
472    /// Add a form XObject
473    pub fn add_form(&mut self, name: Option<String>, form: FormXObject) -> String {
474        let name = name.unwrap_or_else(|| {
475            let id = format!("Fm{}", self.next_id);
476            self.next_id += 1;
477            id
478        });
479
480        self.forms.insert(name.clone(), form);
481        name
482    }
483
484    /// Get a form XObject
485    pub fn get_form(&self, name: &str) -> Option<&FormXObject> {
486        self.forms.get(name)
487    }
488
489    /// Get all forms
490    pub fn get_all_forms(&self) -> &HashMap<String, FormXObject> {
491        &self.forms
492    }
493
494    /// Remove a form
495    pub fn remove_form(&mut self, name: &str) -> Option<FormXObject> {
496        self.forms.remove(name)
497    }
498
499    /// Clear all forms
500    pub fn clear(&mut self) {
501        self.forms.clear();
502        self.next_id = 1;
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::geometry::Point;
510
511    #[test]
512    fn test_form_xobject_creation() {
513        let bbox = Rectangle::new(Point::new(0.0, 0.0), Point::new(100.0, 100.0));
514        let form = FormXObject::new(bbox.clone());
515
516        assert_eq!(form.bbox, bbox);
517        assert!(form.matrix.is_none());
518        assert!(form.content.is_empty());
519    }
520
521    #[test]
522    fn test_form_xobject_with_matrix() {
523        let bbox = Rectangle::new(Point::new(0.0, 0.0), Point::new(50.0, 50.0));
524        let matrix = [2.0, 0.0, 0.0, 2.0, 10.0, 10.0]; // Scale by 2, translate by (10, 10)
525
526        let form = FormXObject::new(bbox).with_matrix(matrix);
527
528        assert_eq!(form.matrix, Some(matrix));
529    }
530
531    #[test]
532    fn test_form_xobject_from_graphics_ops() {
533        let bbox = Rectangle::new(Point::new(0.0, 0.0), Point::new(100.0, 100.0));
534        let ops = "0 0 100 100 re\nf";
535
536        let form = FormXObject::from_graphics_ops(bbox.clone(), ops);
537
538        assert_eq!(form.bbox, bbox);
539        assert_eq!(form.content, ops.as_bytes());
540    }
541
542    #[test]
543    fn test_form_xobject_to_stream() {
544        let bbox = Rectangle::new(Point::new(0.0, 0.0), Point::new(200.0, 100.0));
545        let form = FormXObject::new(bbox).with_content(b"q\n1 0 0 1 0 0 cm\nQ".to_vec());
546
547        let stream = form.to_stream();
548        assert!(stream.is_ok());
549
550        let stream = stream.unwrap();
551        let dict = stream.dictionary();
552
553        assert_eq!(dict.get("Type"), Some(&Object::Name("XObject".to_string())));
554        assert_eq!(dict.get("Subtype"), Some(&Object::Name("Form".to_string())));
555        assert!(dict.get("BBox").is_some());
556    }
557
558    #[test]
559    fn test_transparency_group() {
560        let bbox = Rectangle::new(Point::new(0.0, 0.0), Point::new(100.0, 100.0));
561        let group = TransparencyGroup {
562            color_space: "DeviceCMYK".to_string(),
563            isolated: true,
564            knockout: false,
565        };
566
567        let form = FormXObject::new(bbox).with_transparency_group(group);
568
569        assert!(form.has_transparency());
570        assert_eq!(form.group.as_ref().unwrap().color_space, "DeviceCMYK");
571        assert!(form.group.as_ref().unwrap().isolated);
572    }
573
574    #[test]
575    fn test_form_builder_basic() {
576        let bbox = Rectangle::new(Point::new(0.0, 0.0), Point::new(100.0, 100.0));
577
578        let form = FormXObjectBuilder::new(bbox)
579            .fill_color(1.0, 0.0, 0.0)
580            .rectangle(10.0, 10.0, 80.0, 80.0)
581            .fill()
582            .build();
583
584        let content = String::from_utf8(form.content).unwrap();
585        // After issue #220/#221 helper migration, fill_color/stroke_color
586        // emit `.3`-precision tokens — `1.000 0.000 0.000 rg`, not `1 0 0 rg`.
587        assert!(content.contains("1.000 0.000 0.000 rg"));
588        assert!(content.contains("10 10 80 80 re"));
589        assert!(content.contains("f"));
590    }
591
592    #[test]
593    fn test_form_builder_complex() {
594        let bbox = Rectangle::new(Point::new(0.0, 0.0), Point::new(200.0, 200.0));
595
596        let form = FormXObjectBuilder::new(bbox)
597            .save_state()
598            .stroke_color(0.0, 0.0, 1.0)
599            .move_to(50.0, 50.0)
600            .line_to(150.0, 150.0)
601            .stroke()
602            .restore_state()
603            .transparency_group(true, false)
604            .build();
605
606        let content = String::from_utf8(form.content.clone()).unwrap();
607        assert!(content.contains("q"));
608        assert!(content.contains("Q"));
609        assert!(content.contains("0.000 0.000 1.000 RG"));
610        assert!(form.has_transparency());
611    }
612
613    #[test]
614    fn test_form_templates_checkmark() {
615        let form = FormTemplates::checkmark(20.0);
616
617        assert_eq!(form.bbox.width(), 20.0);
618        assert_eq!(form.bbox.height(), 20.0);
619
620        let content = String::from_utf8(form.content).unwrap();
621        assert!(content.contains("0.000 0.500 0.000 RG")); // Green color
622    }
623
624    #[test]
625    fn test_form_templates_cross() {
626        let form = FormTemplates::cross(30.0);
627
628        assert_eq!(form.bbox.width(), 30.0);
629
630        let content = String::from_utf8(form.content).unwrap();
631        assert!(content.contains("0.800 0.000 0.000 RG")); // Red color
632    }
633
634    #[test]
635    fn test_form_templates_circle() {
636        let filled_circle = FormTemplates::circle(25.0, true);
637        let stroked_circle = FormTemplates::circle(25.0, false);
638
639        assert_eq!(filled_circle.bbox.width(), 50.0);
640        assert_eq!(stroked_circle.bbox.width(), 50.0);
641
642        let filled_content = String::from_utf8(filled_circle.content).unwrap();
643        let stroked_content = String::from_utf8(stroked_circle.content).unwrap();
644
645        assert!(filled_content.contains("f")); // Fill
646        assert!(stroked_content.contains("S")); // Stroke
647    }
648
649    #[test]
650    fn test_form_templates_star() {
651        let star = FormTemplates::star(100.0, 5);
652
653        assert_eq!(star.bbox.width(), 100.0);
654
655        let content = String::from_utf8(star.content).unwrap();
656        assert!(content.contains("1.000 0.800 0.000 rg")); // Gold color
657    }
658
659    #[test]
660    fn test_form_xobject_manager() {
661        let mut manager = FormXObjectManager::new();
662
663        let bbox = Rectangle::new(Point::new(0.0, 0.0), Point::new(50.0, 50.0));
664        let form1 = FormXObject::new(bbox.clone());
665        let form2 = FormXObject::new(bbox);
666
667        let name1 = manager.add_form(Some("custom".to_string()), form1);
668        let name2 = manager.add_form(None, form2);
669
670        assert_eq!(name1, "custom");
671        assert!(name2.starts_with("Fm"));
672
673        assert!(manager.get_form("custom").is_some());
674        assert!(manager.get_form(&name2).is_some());
675        assert!(manager.get_form("nonexistent").is_none());
676    }
677
678    #[test]
679    fn test_form_xobject_manager_operations() {
680        let mut manager = FormXObjectManager::new();
681
682        let bbox = Rectangle::new(Point::new(0.0, 0.0), Point::new(100.0, 100.0));
683        let form = FormXObject::new(bbox);
684
685        manager.add_form(Some("test".to_string()), form.clone());
686        assert_eq!(manager.get_all_forms().len(), 1);
687
688        let removed = manager.remove_form("test");
689        assert!(removed.is_some());
690        assert_eq!(manager.get_all_forms().len(), 0);
691
692        manager.add_form(None, form);
693        manager.clear();
694        assert_eq!(manager.get_all_forms().len(), 0);
695    }
696}