Skip to main content

oxidize_pdf/graphics/
transparency.rs

1//! Transparency Groups implementation for ISO 32000-1:2008 Section 11.4
2//!
3//! Transparency groups allow applying transparency effects to a collection
4//! of objects as a single unit rather than individually.
5
6use super::state::BlendMode;
7use crate::objects::{Dictionary, Object};
8
9/// Transparency group attributes according to ISO 32000-1:2008 Section 11.4.5
10#[derive(Debug, Clone)]
11pub struct TransparencyGroup {
12    /// Whether the group is isolated from its backdrop
13    /// When true, the group is composited against a fully transparent backdrop
14    /// When false, the group inherits the backdrop from its parent
15    pub isolated: bool,
16
17    /// Whether the group is a knockout group
18    /// When true, objects within the group knock out (replace) earlier objects
19    /// When false, objects composite normally with each other
20    pub knockout: bool,
21
22    /// The blend mode to apply to the entire group
23    pub blend_mode: BlendMode,
24
25    /// The opacity to apply to the entire group (0.0 = transparent, 1.0 = opaque)
26    pub opacity: f32,
27
28    /// Optional color space for the group
29    pub color_space: Option<String>,
30}
31
32impl Default for TransparencyGroup {
33    fn default() -> Self {
34        Self {
35            isolated: false,
36            knockout: false,
37            blend_mode: BlendMode::Normal,
38            opacity: 1.0,
39            color_space: None,
40        }
41    }
42}
43
44impl TransparencyGroup {
45    /// Create a new transparency group with default settings
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Create an isolated transparency group
51    pub fn isolated() -> Self {
52        Self {
53            isolated: true,
54            ..Default::default()
55        }
56    }
57
58    /// Create a knockout transparency group
59    pub fn knockout() -> Self {
60        Self {
61            knockout: true,
62            ..Default::default()
63        }
64    }
65
66    /// Set whether the group is isolated
67    pub fn with_isolated(mut self, isolated: bool) -> Self {
68        self.isolated = isolated;
69        self
70    }
71
72    /// Set whether the group is a knockout group
73    pub fn with_knockout(mut self, knockout: bool) -> Self {
74        self.knockout = knockout;
75        self
76    }
77
78    /// Set the blend mode for the group
79    pub fn with_blend_mode(mut self, blend_mode: BlendMode) -> Self {
80        self.blend_mode = blend_mode;
81        self
82    }
83
84    /// Set the opacity for the group
85    pub fn with_opacity(mut self, opacity: f32) -> Self {
86        self.opacity = opacity.clamp(0.0, 1.0);
87        self
88    }
89
90    /// Set the color space for the group
91    pub fn with_color_space(mut self, color_space: impl Into<String>) -> Self {
92        self.color_space = Some(color_space.into());
93        self
94    }
95
96    /// Convert to PDF dictionary representation
97    pub fn to_dict(&self) -> Dictionary {
98        let mut dict = Dictionary::new();
99
100        // Required entries
101        dict.set("Type", Object::Name("Group".into()));
102        dict.set("S", Object::Name("Transparency".into()));
103
104        // Optional entries
105        if self.isolated {
106            dict.set("I", Object::Boolean(true));
107        }
108
109        if self.knockout {
110            dict.set("K", Object::Boolean(true));
111        }
112
113        if let Some(ref cs) = self.color_space {
114            dict.set("CS", Object::Name(cs.clone()));
115        }
116
117        dict
118    }
119}
120
121/// Stack entry for managing nested transparency groups.
122///
123/// `saved_state` and `content` were removed in v2.7.0 — both fields
124/// were filled at every `begin_transparency_group` call but never
125/// read by any consumer. Snapshotting the entire serialised content
126/// stream on every entry was a non-trivial cost (allocates a fresh
127/// `Vec<u8>` proportional to the page's content size) for no benefit.
128#[derive(Debug, Clone)]
129pub(crate) struct TransparencyGroupState {
130    /// The transparency group configuration
131    pub group: TransparencyGroup,
132}
133
134impl TransparencyGroupState {
135    /// Create a new transparency group state
136    pub fn new(group: TransparencyGroup) -> Self {
137        Self { group }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_transparency_group_creation() {
147        let group = TransparencyGroup::new();
148        assert!(!group.isolated);
149        assert!(!group.knockout);
150        assert_eq!(group.opacity, 1.0);
151        assert!(matches!(group.blend_mode, BlendMode::Normal));
152    }
153
154    #[test]
155    fn test_isolated_group() {
156        let group = TransparencyGroup::isolated();
157        assert!(group.isolated);
158        assert!(!group.knockout);
159    }
160
161    #[test]
162    fn test_knockout_group() {
163        let group = TransparencyGroup::knockout();
164        assert!(!group.isolated);
165        assert!(group.knockout);
166    }
167
168    #[test]
169    fn test_group_builder() {
170        let group = TransparencyGroup::new()
171            .with_isolated(true)
172            .with_knockout(true)
173            .with_blend_mode(BlendMode::Multiply)
174            .with_opacity(0.5)
175            .with_color_space("DeviceRGB");
176
177        assert!(group.isolated);
178        assert!(group.knockout);
179        assert_eq!(group.opacity, 0.5);
180        assert!(matches!(group.blend_mode, BlendMode::Multiply));
181        assert_eq!(group.color_space, Some("DeviceRGB".to_string()));
182    }
183
184    #[test]
185    fn test_opacity_clamping() {
186        let group1 = TransparencyGroup::new().with_opacity(1.5);
187        assert_eq!(group1.opacity, 1.0);
188
189        let group2 = TransparencyGroup::new().with_opacity(-0.5);
190        assert_eq!(group2.opacity, 0.0);
191    }
192
193    #[test]
194    fn test_to_dict() {
195        let group = TransparencyGroup::new()
196            .with_isolated(true)
197            .with_knockout(true)
198            .with_color_space("DeviceCMYK");
199
200        let dict = group.to_dict();
201
202        // Check required entries
203        assert_eq!(dict.get("Type"), Some(&Object::Name("Group".into())));
204        assert_eq!(dict.get("S"), Some(&Object::Name("Transparency".into())));
205
206        // Check optional entries
207        assert_eq!(dict.get("I"), Some(&Object::Boolean(true)));
208        assert_eq!(dict.get("K"), Some(&Object::Boolean(true)));
209        assert_eq!(dict.get("CS"), Some(&Object::Name("DeviceCMYK".into())));
210    }
211
212    #[test]
213    fn test_default_dict() {
214        let group = TransparencyGroup::new();
215        let dict = group.to_dict();
216
217        // Should only have required entries for default group
218        assert_eq!(dict.get("Type"), Some(&Object::Name("Group".into())));
219        assert_eq!(dict.get("S"), Some(&Object::Name("Transparency".into())));
220        assert!(dict.get("I").is_none());
221        assert!(dict.get("K").is_none());
222        assert!(dict.get("CS").is_none());
223    }
224}