oxidize_pdf/graphics/
mod.rs

1mod color;
2mod image;
3mod path;
4
5pub use color::Color;
6pub use image::{ColorSpace as ImageColorSpace, Image, ImageFormat};
7pub use path::{LineCap, LineJoin, PathBuilder};
8
9use crate::error::Result;
10use std::fmt::Write;
11
12#[derive(Clone)]
13pub struct GraphicsContext {
14    operations: String,
15    current_color: Color,
16    stroke_color: Color,
17    line_width: f64,
18    fill_opacity: f64,
19    stroke_opacity: f64,
20}
21
22impl Default for GraphicsContext {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl GraphicsContext {
29    pub fn new() -> Self {
30        Self {
31            operations: String::new(),
32            current_color: Color::black(),
33            stroke_color: Color::black(),
34            line_width: 1.0,
35            fill_opacity: 1.0,
36            stroke_opacity: 1.0,
37        }
38    }
39
40    pub fn move_to(&mut self, x: f64, y: f64) -> &mut Self {
41        writeln!(&mut self.operations, "{x:.2} {y:.2} m").unwrap();
42        self
43    }
44
45    pub fn line_to(&mut self, x: f64, y: f64) -> &mut Self {
46        writeln!(&mut self.operations, "{x:.2} {y:.2} l").unwrap();
47        self
48    }
49
50    pub fn curve_to(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> &mut Self {
51        writeln!(
52            &mut self.operations,
53            "{x1:.2} {y1:.2} {x2:.2} {y2:.2} {x3:.2} {y3:.2} c"
54        )
55        .unwrap();
56        self
57    }
58
59    pub fn rect(&mut self, x: f64, y: f64, width: f64, height: f64) -> &mut Self {
60        writeln!(
61            &mut self.operations,
62            "{x:.2} {y:.2} {width:.2} {height:.2} re"
63        )
64        .unwrap();
65        self
66    }
67
68    pub fn circle(&mut self, cx: f64, cy: f64, radius: f64) -> &mut Self {
69        let k = 0.552284749831;
70        let r = radius;
71
72        self.move_to(cx + r, cy);
73        self.curve_to(cx + r, cy + k * r, cx + k * r, cy + r, cx, cy + r);
74        self.curve_to(cx - k * r, cy + r, cx - r, cy + k * r, cx - r, cy);
75        self.curve_to(cx - r, cy - k * r, cx - k * r, cy - r, cx, cy - r);
76        self.curve_to(cx + k * r, cy - r, cx + r, cy - k * r, cx + r, cy);
77        self.close_path()
78    }
79
80    pub fn close_path(&mut self) -> &mut Self {
81        self.operations.push_str("h\n");
82        self
83    }
84
85    pub fn stroke(&mut self) -> &mut Self {
86        self.apply_stroke_color();
87        self.operations.push_str("S\n");
88        self
89    }
90
91    pub fn fill(&mut self) -> &mut Self {
92        self.apply_fill_color();
93        self.operations.push_str("f\n");
94        self
95    }
96
97    pub fn fill_stroke(&mut self) -> &mut Self {
98        self.apply_fill_color();
99        self.apply_stroke_color();
100        self.operations.push_str("B\n");
101        self
102    }
103
104    pub fn set_stroke_color(&mut self, color: Color) -> &mut Self {
105        self.stroke_color = color;
106        self
107    }
108
109    pub fn set_fill_color(&mut self, color: Color) -> &mut Self {
110        self.current_color = color;
111        self
112    }
113
114    pub fn set_line_width(&mut self, width: f64) -> &mut Self {
115        self.line_width = width;
116        writeln!(&mut self.operations, "{width:.2} w").unwrap();
117        self
118    }
119
120    pub fn set_line_cap(&mut self, cap: LineCap) -> &mut Self {
121        writeln!(&mut self.operations, "{} J", cap as u8).unwrap();
122        self
123    }
124
125    pub fn set_line_join(&mut self, join: LineJoin) -> &mut Self {
126        writeln!(&mut self.operations, "{} j", join as u8).unwrap();
127        self
128    }
129
130    /// Set the opacity for both fill and stroke operations (0.0 to 1.0)
131    pub fn set_opacity(&mut self, opacity: f64) -> &mut Self {
132        let opacity = opacity.clamp(0.0, 1.0);
133        self.fill_opacity = opacity;
134        self.stroke_opacity = opacity;
135        self
136    }
137
138    /// Set the fill opacity (0.0 to 1.0)
139    pub fn set_fill_opacity(&mut self, opacity: f64) -> &mut Self {
140        self.fill_opacity = opacity.clamp(0.0, 1.0);
141        self
142    }
143
144    /// Set the stroke opacity (0.0 to 1.0)
145    pub fn set_stroke_opacity(&mut self, opacity: f64) -> &mut Self {
146        self.stroke_opacity = opacity.clamp(0.0, 1.0);
147        self
148    }
149
150    pub fn save_state(&mut self) -> &mut Self {
151        self.operations.push_str("q\n");
152        self
153    }
154
155    pub fn restore_state(&mut self) -> &mut Self {
156        self.operations.push_str("Q\n");
157        self
158    }
159
160    pub fn translate(&mut self, tx: f64, ty: f64) -> &mut Self {
161        writeln!(&mut self.operations, "1 0 0 1 {tx:.2} {ty:.2} cm").unwrap();
162        self
163    }
164
165    pub fn scale(&mut self, sx: f64, sy: f64) -> &mut Self {
166        writeln!(&mut self.operations, "{sx:.2} 0 0 {sy:.2} 0 0 cm").unwrap();
167        self
168    }
169
170    pub fn rotate(&mut self, angle: f64) -> &mut Self {
171        let cos = angle.cos();
172        let sin = angle.sin();
173        writeln!(
174            &mut self.operations,
175            "{:.6} {:.6} {:.6} {:.6} 0 0 cm",
176            cos, sin, -sin, cos
177        )
178        .unwrap();
179        self
180    }
181
182    pub fn transform(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) -> &mut Self {
183        writeln!(
184            &mut self.operations,
185            "{a:.2} {b:.2} {c:.2} {d:.2} {e:.2} {f:.2} cm"
186        )
187        .unwrap();
188        self
189    }
190
191    pub fn rectangle(&mut self, x: f64, y: f64, width: f64, height: f64) -> &mut Self {
192        self.rect(x, y, width, height)
193    }
194
195    pub fn draw_image(
196        &mut self,
197        image_name: &str,
198        x: f64,
199        y: f64,
200        width: f64,
201        height: f64,
202    ) -> &mut Self {
203        // Save graphics state
204        self.save_state();
205
206        // Set up transformation matrix for image placement
207        // PDF coordinate system has origin at bottom-left, so we need to translate and scale
208        writeln!(
209            &mut self.operations,
210            "{width:.2} 0 0 {height:.2} {x:.2} {y:.2} cm"
211        )
212        .unwrap();
213
214        // Draw the image XObject
215        writeln!(&mut self.operations, "/{image_name} Do").unwrap();
216
217        // Restore graphics state
218        self.restore_state();
219
220        self
221    }
222
223    fn apply_stroke_color(&mut self) {
224        match self.stroke_color {
225            Color::Rgb(r, g, b) => {
226                writeln!(&mut self.operations, "{r:.3} {g:.3} {b:.3} RG").unwrap();
227            }
228            Color::Gray(g) => {
229                writeln!(&mut self.operations, "{g:.3} G").unwrap();
230            }
231            Color::Cmyk(c, m, y, k) => {
232                writeln!(&mut self.operations, "{c:.3} {m:.3} {y:.3} {k:.3} K").unwrap();
233            }
234        }
235    }
236
237    fn apply_fill_color(&mut self) {
238        match self.current_color {
239            Color::Rgb(r, g, b) => {
240                writeln!(&mut self.operations, "{r:.3} {g:.3} {b:.3} rg").unwrap();
241            }
242            Color::Gray(g) => {
243                writeln!(&mut self.operations, "{g:.3} g").unwrap();
244            }
245            Color::Cmyk(c, m, y, k) => {
246                writeln!(&mut self.operations, "{c:.3} {m:.3} {y:.3} {k:.3} k").unwrap();
247            }
248        }
249    }
250
251    pub(crate) fn generate_operations(&self) -> Result<Vec<u8>> {
252        Ok(self.operations.as_bytes().to_vec())
253    }
254
255    /// Check if transparency is used (opacity != 1.0)
256    pub fn uses_transparency(&self) -> bool {
257        self.fill_opacity < 1.0 || self.stroke_opacity < 1.0
258    }
259
260    /// Generate the graphics state dictionary for transparency
261    pub fn generate_graphics_state_dict(&self) -> Option<String> {
262        if !self.uses_transparency() {
263            return None;
264        }
265
266        let mut dict = String::from("<< /Type /ExtGState");
267
268        if self.fill_opacity < 1.0 {
269            write!(&mut dict, " /ca {:.3}", self.fill_opacity).unwrap();
270        }
271
272        if self.stroke_opacity < 1.0 {
273            write!(&mut dict, " /CA {:.3}", self.stroke_opacity).unwrap();
274        }
275
276        dict.push_str(" >>");
277        Some(dict)
278    }
279
280    /// Get the current fill color
281    pub fn fill_color(&self) -> Color {
282        self.current_color
283    }
284
285    /// Get the current stroke color
286    pub fn stroke_color(&self) -> Color {
287        self.stroke_color
288    }
289
290    /// Get the current line width
291    pub fn line_width(&self) -> f64 {
292        self.line_width
293    }
294
295    /// Get the current fill opacity
296    pub fn fill_opacity(&self) -> f64 {
297        self.fill_opacity
298    }
299
300    /// Get the current stroke opacity
301    pub fn stroke_opacity(&self) -> f64 {
302        self.stroke_opacity
303    }
304
305    /// Get the operations string
306    pub fn operations(&self) -> &str {
307        &self.operations
308    }
309
310    /// Clear all operations
311    pub fn clear(&mut self) {
312        self.operations.clear();
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_graphics_context_new() {
322        let ctx = GraphicsContext::new();
323        assert_eq!(ctx.fill_color(), Color::black());
324        assert_eq!(ctx.stroke_color(), Color::black());
325        assert_eq!(ctx.line_width(), 1.0);
326        assert_eq!(ctx.fill_opacity(), 1.0);
327        assert_eq!(ctx.stroke_opacity(), 1.0);
328        assert!(ctx.operations().is_empty());
329    }
330
331    #[test]
332    fn test_graphics_context_default() {
333        let ctx = GraphicsContext::default();
334        assert_eq!(ctx.fill_color(), Color::black());
335        assert_eq!(ctx.stroke_color(), Color::black());
336        assert_eq!(ctx.line_width(), 1.0);
337    }
338
339    #[test]
340    fn test_move_to() {
341        let mut ctx = GraphicsContext::new();
342        ctx.move_to(10.0, 20.0);
343        assert!(ctx.operations().contains("10.00 20.00 m\n"));
344    }
345
346    #[test]
347    fn test_line_to() {
348        let mut ctx = GraphicsContext::new();
349        ctx.line_to(30.0, 40.0);
350        assert!(ctx.operations().contains("30.00 40.00 l\n"));
351    }
352
353    #[test]
354    fn test_curve_to() {
355        let mut ctx = GraphicsContext::new();
356        ctx.curve_to(10.0, 20.0, 30.0, 40.0, 50.0, 60.0);
357        assert!(ctx
358            .operations()
359            .contains("10.00 20.00 30.00 40.00 50.00 60.00 c\n"));
360    }
361
362    #[test]
363    fn test_rect() {
364        let mut ctx = GraphicsContext::new();
365        ctx.rect(10.0, 20.0, 100.0, 50.0);
366        assert!(ctx.operations().contains("10.00 20.00 100.00 50.00 re\n"));
367    }
368
369    #[test]
370    fn test_rectangle_alias() {
371        let mut ctx = GraphicsContext::new();
372        ctx.rectangle(10.0, 20.0, 100.0, 50.0);
373        assert!(ctx.operations().contains("10.00 20.00 100.00 50.00 re\n"));
374    }
375
376    #[test]
377    fn test_circle() {
378        let mut ctx = GraphicsContext::new();
379        ctx.circle(50.0, 50.0, 25.0);
380
381        let ops = ctx.operations();
382        // Check that it starts with move to radius point
383        assert!(ops.contains("75.00 50.00 m\n"));
384        // Check that it contains curve operations
385        assert!(ops.contains(" c\n"));
386        // Check that it closes the path
387        assert!(ops.contains("h\n"));
388    }
389
390    #[test]
391    fn test_close_path() {
392        let mut ctx = GraphicsContext::new();
393        ctx.close_path();
394        assert!(ctx.operations().contains("h\n"));
395    }
396
397    #[test]
398    fn test_stroke() {
399        let mut ctx = GraphicsContext::new();
400        ctx.set_stroke_color(Color::red());
401        ctx.rect(0.0, 0.0, 10.0, 10.0);
402        ctx.stroke();
403
404        let ops = ctx.operations();
405        assert!(ops.contains("1.000 0.000 0.000 RG\n"));
406        assert!(ops.contains("S\n"));
407    }
408
409    #[test]
410    fn test_fill() {
411        let mut ctx = GraphicsContext::new();
412        ctx.set_fill_color(Color::blue());
413        ctx.rect(0.0, 0.0, 10.0, 10.0);
414        ctx.fill();
415
416        let ops = ctx.operations();
417        assert!(ops.contains("0.000 0.000 1.000 rg\n"));
418        assert!(ops.contains("f\n"));
419    }
420
421    #[test]
422    fn test_fill_stroke() {
423        let mut ctx = GraphicsContext::new();
424        ctx.set_fill_color(Color::green());
425        ctx.set_stroke_color(Color::red());
426        ctx.rect(0.0, 0.0, 10.0, 10.0);
427        ctx.fill_stroke();
428
429        let ops = ctx.operations();
430        assert!(ops.contains("0.000 1.000 0.000 rg\n"));
431        assert!(ops.contains("1.000 0.000 0.000 RG\n"));
432        assert!(ops.contains("B\n"));
433    }
434
435    #[test]
436    fn test_set_stroke_color() {
437        let mut ctx = GraphicsContext::new();
438        ctx.set_stroke_color(Color::rgb(0.5, 0.6, 0.7));
439        assert_eq!(ctx.stroke_color(), Color::Rgb(0.5, 0.6, 0.7));
440    }
441
442    #[test]
443    fn test_set_fill_color() {
444        let mut ctx = GraphicsContext::new();
445        ctx.set_fill_color(Color::gray(0.5));
446        assert_eq!(ctx.fill_color(), Color::Gray(0.5));
447    }
448
449    #[test]
450    fn test_set_line_width() {
451        let mut ctx = GraphicsContext::new();
452        ctx.set_line_width(2.5);
453        assert_eq!(ctx.line_width(), 2.5);
454        assert!(ctx.operations().contains("2.50 w\n"));
455    }
456
457    #[test]
458    fn test_set_line_cap() {
459        let mut ctx = GraphicsContext::new();
460        ctx.set_line_cap(LineCap::Round);
461        assert!(ctx.operations().contains("1 J\n"));
462
463        ctx.set_line_cap(LineCap::Butt);
464        assert!(ctx.operations().contains("0 J\n"));
465
466        ctx.set_line_cap(LineCap::Square);
467        assert!(ctx.operations().contains("2 J\n"));
468    }
469
470    #[test]
471    fn test_set_line_join() {
472        let mut ctx = GraphicsContext::new();
473        ctx.set_line_join(LineJoin::Round);
474        assert!(ctx.operations().contains("1 j\n"));
475
476        ctx.set_line_join(LineJoin::Miter);
477        assert!(ctx.operations().contains("0 j\n"));
478
479        ctx.set_line_join(LineJoin::Bevel);
480        assert!(ctx.operations().contains("2 j\n"));
481    }
482
483    #[test]
484    fn test_save_restore_state() {
485        let mut ctx = GraphicsContext::new();
486        ctx.save_state();
487        assert!(ctx.operations().contains("q\n"));
488
489        ctx.restore_state();
490        assert!(ctx.operations().contains("Q\n"));
491    }
492
493    #[test]
494    fn test_translate() {
495        let mut ctx = GraphicsContext::new();
496        ctx.translate(50.0, 100.0);
497        assert!(ctx.operations().contains("1 0 0 1 50.00 100.00 cm\n"));
498    }
499
500    #[test]
501    fn test_scale() {
502        let mut ctx = GraphicsContext::new();
503        ctx.scale(2.0, 3.0);
504        assert!(ctx.operations().contains("2.00 0 0 3.00 0 0 cm\n"));
505    }
506
507    #[test]
508    fn test_rotate() {
509        let mut ctx = GraphicsContext::new();
510        let angle = std::f64::consts::PI / 4.0; // 45 degrees
511        ctx.rotate(angle);
512
513        let ops = ctx.operations();
514        assert!(ops.contains(" cm\n"));
515        // Should contain cos and sin values
516        assert!(ops.contains("0.707107")); // Approximate cos(45°)
517    }
518
519    #[test]
520    fn test_transform() {
521        let mut ctx = GraphicsContext::new();
522        ctx.transform(1.0, 2.0, 3.0, 4.0, 5.0, 6.0);
523        assert!(ctx
524            .operations()
525            .contains("1.00 2.00 3.00 4.00 5.00 6.00 cm\n"));
526    }
527
528    #[test]
529    fn test_draw_image() {
530        let mut ctx = GraphicsContext::new();
531        ctx.draw_image("Image1", 10.0, 20.0, 100.0, 150.0);
532
533        let ops = ctx.operations();
534        assert!(ops.contains("q\n")); // Save state
535        assert!(ops.contains("100.00 0 0 150.00 10.00 20.00 cm\n")); // Transform
536        assert!(ops.contains("/Image1 Do\n")); // Draw image
537        assert!(ops.contains("Q\n")); // Restore state
538    }
539
540    #[test]
541    fn test_gray_color_operations() {
542        let mut ctx = GraphicsContext::new();
543        ctx.set_stroke_color(Color::gray(0.5));
544        ctx.set_fill_color(Color::gray(0.7));
545        ctx.stroke();
546        ctx.fill();
547
548        let ops = ctx.operations();
549        assert!(ops.contains("0.500 G\n")); // Stroke gray
550        assert!(ops.contains("0.700 g\n")); // Fill gray
551    }
552
553    #[test]
554    fn test_cmyk_color_operations() {
555        let mut ctx = GraphicsContext::new();
556        ctx.set_stroke_color(Color::cmyk(0.1, 0.2, 0.3, 0.4));
557        ctx.set_fill_color(Color::cmyk(0.5, 0.6, 0.7, 0.8));
558        ctx.stroke();
559        ctx.fill();
560
561        let ops = ctx.operations();
562        assert!(ops.contains("0.100 0.200 0.300 0.400 K\n")); // Stroke CMYK
563        assert!(ops.contains("0.500 0.600 0.700 0.800 k\n")); // Fill CMYK
564    }
565
566    #[test]
567    fn test_method_chaining() {
568        let mut ctx = GraphicsContext::new();
569        ctx.move_to(0.0, 0.0)
570            .line_to(10.0, 0.0)
571            .line_to(10.0, 10.0)
572            .line_to(0.0, 10.0)
573            .close_path()
574            .set_fill_color(Color::red())
575            .fill();
576
577        let ops = ctx.operations();
578        assert!(ops.contains("0.00 0.00 m\n"));
579        assert!(ops.contains("10.00 0.00 l\n"));
580        assert!(ops.contains("10.00 10.00 l\n"));
581        assert!(ops.contains("0.00 10.00 l\n"));
582        assert!(ops.contains("h\n"));
583        assert!(ops.contains("f\n"));
584    }
585
586    #[test]
587    fn test_generate_operations() {
588        let mut ctx = GraphicsContext::new();
589        ctx.rect(0.0, 0.0, 10.0, 10.0);
590
591        let result = ctx.generate_operations();
592        assert!(result.is_ok());
593        let bytes = result.unwrap();
594        let ops_string = String::from_utf8(bytes).unwrap();
595        assert!(ops_string.contains("0.00 0.00 10.00 10.00 re"));
596    }
597
598    #[test]
599    fn test_clear_operations() {
600        let mut ctx = GraphicsContext::new();
601        ctx.rect(0.0, 0.0, 10.0, 10.0);
602        assert!(!ctx.operations().is_empty());
603
604        ctx.clear();
605        assert!(ctx.operations().is_empty());
606    }
607
608    #[test]
609    fn test_complex_path() {
610        let mut ctx = GraphicsContext::new();
611        ctx.save_state()
612            .translate(100.0, 100.0)
613            .rotate(std::f64::consts::PI / 6.0)
614            .scale(2.0, 2.0)
615            .set_line_width(2.0)
616            .set_stroke_color(Color::blue())
617            .move_to(0.0, 0.0)
618            .line_to(50.0, 0.0)
619            .curve_to(50.0, 25.0, 25.0, 50.0, 0.0, 50.0)
620            .close_path()
621            .stroke()
622            .restore_state();
623
624        let ops = ctx.operations();
625        assert!(ops.contains("q\n"));
626        assert!(ops.contains("cm\n"));
627        assert!(ops.contains("2.00 w\n"));
628        assert!(ops.contains("0.000 0.000 1.000 RG\n"));
629        assert!(ops.contains("S\n"));
630        assert!(ops.contains("Q\n"));
631    }
632
633    #[test]
634    fn test_graphics_context_clone() {
635        let mut ctx = GraphicsContext::new();
636        ctx.set_fill_color(Color::red());
637        ctx.set_stroke_color(Color::blue());
638        ctx.set_line_width(3.0);
639        ctx.set_opacity(0.5);
640        ctx.rect(0.0, 0.0, 10.0, 10.0);
641
642        let ctx_clone = ctx.clone();
643        assert_eq!(ctx_clone.fill_color(), Color::red());
644        assert_eq!(ctx_clone.stroke_color(), Color::blue());
645        assert_eq!(ctx_clone.line_width(), 3.0);
646        assert_eq!(ctx_clone.fill_opacity(), 0.5);
647        assert_eq!(ctx_clone.stroke_opacity(), 0.5);
648        assert_eq!(ctx_clone.operations(), ctx.operations());
649    }
650
651    #[test]
652    fn test_set_opacity() {
653        let mut ctx = GraphicsContext::new();
654
655        // Test setting opacity
656        ctx.set_opacity(0.5);
657        assert_eq!(ctx.fill_opacity(), 0.5);
658        assert_eq!(ctx.stroke_opacity(), 0.5);
659
660        // Test clamping to valid range
661        ctx.set_opacity(1.5);
662        assert_eq!(ctx.fill_opacity(), 1.0);
663        assert_eq!(ctx.stroke_opacity(), 1.0);
664
665        ctx.set_opacity(-0.5);
666        assert_eq!(ctx.fill_opacity(), 0.0);
667        assert_eq!(ctx.stroke_opacity(), 0.0);
668    }
669
670    #[test]
671    fn test_set_fill_opacity() {
672        let mut ctx = GraphicsContext::new();
673
674        ctx.set_fill_opacity(0.3);
675        assert_eq!(ctx.fill_opacity(), 0.3);
676        assert_eq!(ctx.stroke_opacity(), 1.0); // Should not affect stroke
677
678        // Test clamping
679        ctx.set_fill_opacity(2.0);
680        assert_eq!(ctx.fill_opacity(), 1.0);
681    }
682
683    #[test]
684    fn test_set_stroke_opacity() {
685        let mut ctx = GraphicsContext::new();
686
687        ctx.set_stroke_opacity(0.7);
688        assert_eq!(ctx.stroke_opacity(), 0.7);
689        assert_eq!(ctx.fill_opacity(), 1.0); // Should not affect fill
690
691        // Test clamping
692        ctx.set_stroke_opacity(-1.0);
693        assert_eq!(ctx.stroke_opacity(), 0.0);
694    }
695
696    #[test]
697    fn test_uses_transparency() {
698        let mut ctx = GraphicsContext::new();
699
700        // Initially no transparency
701        assert!(!ctx.uses_transparency());
702
703        // With fill transparency
704        ctx.set_fill_opacity(0.5);
705        assert!(ctx.uses_transparency());
706
707        // Reset and test stroke transparency
708        ctx.set_fill_opacity(1.0);
709        assert!(!ctx.uses_transparency());
710        ctx.set_stroke_opacity(0.8);
711        assert!(ctx.uses_transparency());
712
713        // Both transparent
714        ctx.set_fill_opacity(0.5);
715        assert!(ctx.uses_transparency());
716    }
717
718    #[test]
719    fn test_generate_graphics_state_dict() {
720        let mut ctx = GraphicsContext::new();
721
722        // No transparency
723        assert_eq!(ctx.generate_graphics_state_dict(), None);
724
725        // Fill opacity only
726        ctx.set_fill_opacity(0.5);
727        let dict = ctx.generate_graphics_state_dict().unwrap();
728        assert!(dict.contains("/Type /ExtGState"));
729        assert!(dict.contains("/ca 0.500"));
730        assert!(!dict.contains("/CA"));
731
732        // Stroke opacity only
733        ctx.set_fill_opacity(1.0);
734        ctx.set_stroke_opacity(0.75);
735        let dict = ctx.generate_graphics_state_dict().unwrap();
736        assert!(dict.contains("/Type /ExtGState"));
737        assert!(dict.contains("/CA 0.750"));
738        assert!(!dict.contains("/ca"));
739
740        // Both opacities
741        ctx.set_fill_opacity(0.25);
742        let dict = ctx.generate_graphics_state_dict().unwrap();
743        assert!(dict.contains("/Type /ExtGState"));
744        assert!(dict.contains("/ca 0.250"));
745        assert!(dict.contains("/CA 0.750"));
746    }
747
748    #[test]
749    fn test_opacity_with_graphics_operations() {
750        let mut ctx = GraphicsContext::new();
751
752        ctx.set_fill_color(Color::red())
753            .set_opacity(0.5)
754            .rect(10.0, 10.0, 100.0, 100.0)
755            .fill();
756
757        assert_eq!(ctx.fill_opacity(), 0.5);
758        assert_eq!(ctx.stroke_opacity(), 0.5);
759
760        let ops = ctx.operations();
761        assert!(ops.contains("10.00 10.00 100.00 100.00 re"));
762        assert!(ops.contains("1.000 0.000 0.000 rg")); // Red color
763        assert!(ops.contains("f")); // Fill
764    }
765}