Skip to main content

oxidize_pdf/graphics/
mod.rs

1pub mod calibrated_color;
2pub mod clipping;
3pub(crate) mod color;
4mod color_profiles;
5pub mod devicen_color;
6pub mod extraction;
7pub mod form_xobject;
8mod indexed_color;
9pub mod lab_color;
10pub(crate) mod ops;
11pub mod page_color_space;
12mod path;
13mod patterns;
14mod pdf_image;
15mod png_decoder;
16pub mod separation_color;
17mod shadings;
18pub mod soft_mask;
19pub mod state;
20pub mod transparency;
21
22pub use calibrated_color::{CalGrayColorSpace, CalRgbColorSpace, CalibratedColor};
23pub use clipping::{ClippingPath, ClippingRegion};
24pub use color::Color;
25pub use color_profiles::{IccColorSpace, IccProfile, IccProfileManager, StandardIccProfile};
26pub use devicen_color::{
27    AlternateColorSpace as DeviceNAlternateColorSpace, ColorantDefinition, ColorantType,
28    DeviceNAttributes, DeviceNColorSpace, LinearTransform, SampledFunction, TintTransformFunction,
29};
30pub use form_xobject::{
31    FormTemplates, FormXObject, FormXObjectBuilder, FormXObjectManager,
32    TransparencyGroup as FormTransparencyGroup,
33};
34pub use indexed_color::{BaseColorSpace, ColorLookupTable, IndexedColorManager, IndexedColorSpace};
35pub use lab_color::{LabColor, LabColorSpace};
36pub use page_color_space::{DeviceColorSpace, PageColorSpace, ParameterisedFamily};
37pub use path::{LineCap, LineJoin, PathBuilder, PathCommand, WindingRule};
38pub use patterns::{
39    PaintType, PatternGraphicsContext, PatternManager, PatternMatrix, PatternType, TilingPattern,
40    TilingType,
41};
42pub use pdf_image::{ColorSpace, Image, ImageFormat, MaskType};
43pub use separation_color::{
44    AlternateColorSpace, SeparationColor, SeparationColorSpace, SpotColors, TintTransform,
45};
46pub use shadings::{
47    AxialShading, ColorStop, FunctionBasedShading, Point, RadialShading, ShadingDefinition,
48    ShadingManager, ShadingPattern, ShadingType,
49};
50pub use soft_mask::{SoftMask, SoftMaskState, SoftMaskType};
51pub use state::{
52    BlendMode, ExtGState, ExtGStateFont, ExtGStateManager, Halftone, LineDashPattern,
53    RenderingIntent, TransferFunction,
54};
55pub use transparency::TransparencyGroup;
56use transparency::TransparencyGroupState;
57
58use crate::error::Result;
59use crate::text::{ColumnContent, ColumnLayout, Font, FontManager, ListElement, Table};
60use std::collections::{HashMap, HashSet};
61use std::fmt::Write;
62use std::sync::Arc;
63
64/// Saved graphics state for save/restore operations.
65/// Using `Arc<str>` for `font_name` makes `Clone` O(1) — only increments the reference count.
66#[derive(Clone)]
67struct GraphicsState {
68    fill_color: Color,
69    stroke_color: Color,
70    font_name: Option<Arc<str>>,
71    font_size: f64,
72    is_custom_font: bool,
73}
74
75#[derive(Clone)]
76pub struct GraphicsContext {
77    operations: Vec<ops::Op>,
78    current_color: Color,
79    stroke_color: Color,
80    line_width: f64,
81    fill_opacity: f64,
82    stroke_opacity: f64,
83    // Extended Graphics State support
84    extgstate_manager: ExtGStateManager,
85    pending_extgstate: Option<ExtGState>,
86    current_dash_pattern: Option<LineDashPattern>,
87    current_miter_limit: f64,
88    current_line_cap: LineCap,
89    current_line_join: LineJoin,
90    current_rendering_intent: RenderingIntent,
91    current_flatness: f64,
92    current_smoothness: f64,
93    // Clipping support
94    clipping_region: ClippingRegion,
95    // Font management
96    font_manager: Option<Arc<FontManager>>,
97    // State stack for save/restore
98    state_stack: Vec<GraphicsState>,
99    current_font_name: Option<Arc<str>>,
100    current_font_size: f64,
101    // Whether the current font is a custom (Type0/CID) font requiring Unicode encoding
102    is_custom_font: bool,
103    // Character tracking for font subsetting, bucketed by custom-font name
104    // (issue #204 — builtin fonts are not tracked because they don't need
105    // subsetting; a single global set across all fonts caused every font's
106    // subset to include chars drawn with a different font, doubling emitted
107    // size when two fonts in the same family were registered).
108    used_characters_by_font: HashMap<String, HashSet<char>>,
109    // Glyph mapping for Unicode fonts (Unicode code point -> Glyph ID)
110    glyph_mapping: Option<HashMap<u32, u16>>,
111    // Transparency group stack for nested groups
112    transparency_stack: Vec<TransparencyGroupState>,
113}
114
115/// Encode a Unicode character as a CID hex value for Type0/Identity-H fonts.
116/// BMP characters (U+0000..U+FFFF) are written as 4-hex-digit values.
117/// Supplementary plane characters (U+10000..U+10FFFF) are written as UTF-16BE surrogate pairs.
118fn encode_char_as_cid(ch: char, buf: &mut String) {
119    let code = ch as u32;
120    if code <= 0xFFFF {
121        write!(buf, "{:04X}", code).expect("Writing to string should never fail");
122    } else {
123        // UTF-16BE surrogate pair for supplementary planes
124        let adjusted = code - 0x10000;
125        let high = ((adjusted >> 10) & 0x3FF) + 0xD800;
126        let low = (adjusted & 0x3FF) + 0xDC00;
127        write!(buf, "{:04X}{:04X}", high, low).expect("Writing to string should never fail");
128    }
129}
130
131impl Default for GraphicsContext {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137impl GraphicsContext {
138    pub fn new() -> Self {
139        Self {
140            operations: Vec::new(),
141            current_color: Color::black(),
142            stroke_color: Color::black(),
143            line_width: 1.0,
144            fill_opacity: 1.0,
145            stroke_opacity: 1.0,
146            // Extended Graphics State defaults
147            extgstate_manager: ExtGStateManager::new(),
148            pending_extgstate: None,
149            current_dash_pattern: None,
150            current_miter_limit: 10.0,
151            current_line_cap: LineCap::Butt,
152            current_line_join: LineJoin::Miter,
153            current_rendering_intent: RenderingIntent::RelativeColorimetric,
154            current_flatness: 1.0,
155            current_smoothness: 0.0,
156            // Clipping defaults
157            clipping_region: ClippingRegion::new(),
158            // Font defaults
159            font_manager: None,
160            state_stack: Vec::new(),
161            current_font_name: None,
162            current_font_size: 12.0,
163            is_custom_font: false,
164            used_characters_by_font: HashMap::new(),
165            glyph_mapping: None,
166            transparency_stack: Vec::new(),
167        }
168    }
169
170    pub fn move_to(&mut self, x: f64, y: f64) -> &mut Self {
171        self.operations.push(ops::Op::MoveTo { x, y });
172        self
173    }
174
175    pub fn line_to(&mut self, x: f64, y: f64) -> &mut Self {
176        self.operations.push(ops::Op::LineTo { x, y });
177        self
178    }
179
180    pub fn curve_to(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> &mut Self {
181        self.operations.push(ops::Op::CurveTo {
182            x1,
183            y1,
184            x2,
185            y2,
186            x3,
187            y3,
188        });
189        self
190    }
191
192    pub fn rect(&mut self, x: f64, y: f64, width: f64, height: f64) -> &mut Self {
193        self.operations.push(ops::Op::Rect {
194            x,
195            y,
196            w: width,
197            h: height,
198        });
199        self
200    }
201
202    pub fn circle(&mut self, cx: f64, cy: f64, radius: f64) -> &mut Self {
203        let k = 0.552284749831;
204        let r = radius;
205
206        self.move_to(cx + r, cy);
207        self.curve_to(cx + r, cy + k * r, cx + k * r, cy + r, cx, cy + r);
208        self.curve_to(cx - k * r, cy + r, cx - r, cy + k * r, cx - r, cy);
209        self.curve_to(cx - r, cy - k * r, cx - k * r, cy - r, cx, cy - r);
210        self.curve_to(cx + k * r, cy - r, cx + r, cy - k * r, cx + r, cy);
211        self.close_path()
212    }
213
214    pub fn close_path(&mut self) -> &mut Self {
215        self.operations.push(ops::Op::ClosePath);
216        self
217    }
218
219    pub fn stroke(&mut self) -> &mut Self {
220        self.apply_pending_extgstate().unwrap_or_default();
221        self.apply_stroke_color();
222        self.operations.push(ops::Op::Stroke);
223        self
224    }
225
226    pub fn fill(&mut self) -> &mut Self {
227        self.apply_pending_extgstate().unwrap_or_default();
228        self.apply_fill_color();
229        self.operations.push(ops::Op::FillNonZero);
230        self
231    }
232
233    pub fn fill_stroke(&mut self) -> &mut Self {
234        self.apply_pending_extgstate().unwrap_or_default();
235        self.apply_fill_color();
236        self.apply_stroke_color();
237        self.operations.push(ops::Op::FillStroke);
238        self
239    }
240
241    /// Paint a registered shading into the current clip region with the
242    /// `sh` operator (ISO 32000-1 §8.7.4.2, issue #297).
243    ///
244    /// `name` must match a shading registered on the page via
245    /// [`Page::add_shading`]; the writer emits it under
246    /// `/Resources/Shading/<name>`. `sh` fills the current clip (the whole
247    /// page if unclipped), so callers typically wrap it in `q … W n … Q`
248    /// with a clip path to bound the gradient.
249    pub fn paint_shading(&mut self, name: impl Into<String>) -> &mut Self {
250        self.apply_pending_extgstate().unwrap_or_default();
251        self.operations.push(ops::Op::PaintShading(name.into()));
252        self
253    }
254
255    pub fn set_stroke_color(&mut self, color: Color) -> &mut Self {
256        self.stroke_color = color;
257        self
258    }
259
260    pub fn set_fill_color(&mut self, color: Color) -> &mut Self {
261        self.current_color = color;
262        self
263    }
264
265    /// Emit a colour-space selection followed by its components.
266    ///
267    /// Shared by every named colour setter (ICC, calibrated, Lab): each
268    /// produces a colour-space operator (`cs`/`CS`) immediately followed by its
269    /// component operator (`sc`/`SC`) per ISO 32000-1 §8.6.8.
270    fn push_color_space_and_components(
271        &mut self,
272        space: ops::Op,
273        components: ops::Op,
274    ) -> &mut Self {
275        self.operations.push(space);
276        self.operations.push(components);
277        self
278    }
279
280    /// Set fill color using an ICC-based color space already registered on the
281    /// page under `/Resources/ColorSpace/<name>` (see [`crate::page::Page::add_color_space`]).
282    /// `components` are the raw color values for the profile's channel count
283    /// (1=Gray, 3=RGB/Lab, 4=CMYK).
284    ///
285    /// ICC profiles are named dynamically by [`IccProfileManager`], so the
286    /// resource name is supplied by the caller rather than hardcoded.
287    ///
288    /// `components` must be non-empty: an empty list would emit a bare `sc`
289    /// operator with no operands, invalid per ISO 32000-1 §8.6.8.
290    pub fn set_fill_color_icc(
291        &mut self,
292        name: impl Into<String>,
293        components: Vec<f64>,
294    ) -> &mut Self {
295        debug_assert!(
296            !components.is_empty(),
297            "ICC fill colour components must not be empty"
298        );
299        self.push_color_space_and_components(
300            ops::Op::SetFillColorSpace(name.into()),
301            ops::Op::SetFillColorComponents(components),
302        )
303    }
304
305    /// Set stroke color using an ICC-based color space already registered on
306    /// the page under `/Resources/ColorSpace/<name>` (see
307    /// [`crate::page::Page::add_color_space`]). See [`Self::set_fill_color_icc`].
308    ///
309    /// `components` must be non-empty (see [`Self::set_fill_color_icc`]).
310    pub fn set_stroke_color_icc(
311        &mut self,
312        name: impl Into<String>,
313        components: Vec<f64>,
314    ) -> &mut Self {
315        debug_assert!(
316            !components.is_empty(),
317            "ICC stroke colour components must not be empty"
318        );
319        self.push_color_space_and_components(
320            ops::Op::SetStrokeColorSpace(name.into()),
321            ops::Op::SetStrokeColorComponents(components),
322        )
323    }
324
325    /// Set fill color using a calibrated color space registered under a
326    /// caller-supplied resource name (`/Resources/ColorSpace/<name>`).
327    ///
328    /// Unlike [`Self::set_fill_color_calibrated`], which always references the
329    /// single default `CalGray1`/`CalRGB1` slot, this accepts the name of any
330    /// registered calibrated space — removing the one-calibrated-space-per-page
331    /// limitation.
332    pub fn set_fill_color_calibrated_named(
333        &mut self,
334        name: impl Into<String>,
335        color: CalibratedColor,
336    ) -> &mut Self {
337        self.push_color_space_and_components(
338            ops::Op::SetFillColorSpace(name.into()),
339            ops::Op::SetFillColorComponents(color.values()),
340        )
341    }
342
343    /// Set stroke color using a calibrated color space registered under a
344    /// caller-supplied resource name. See [`Self::set_fill_color_calibrated_named`].
345    pub fn set_stroke_color_calibrated_named(
346        &mut self,
347        name: impl Into<String>,
348        color: CalibratedColor,
349    ) -> &mut Self {
350        self.push_color_space_and_components(
351            ops::Op::SetStrokeColorSpace(name.into()),
352            ops::Op::SetStrokeColorComponents(color.values()),
353        )
354    }
355
356    /// Set fill color using a Lab color space registered under a
357    /// caller-supplied resource name (`/Resources/ColorSpace/<name>`).
358    ///
359    /// Companion to [`Self::set_fill_color_lab`] (which references the default
360    /// `Lab1` slot); accepts any registered Lab space so multiple Lab spaces
361    /// can coexist on one page.
362    pub fn set_fill_color_lab_named(
363        &mut self,
364        name: impl Into<String>,
365        color: LabColor,
366    ) -> &mut Self {
367        self.push_color_space_and_components(
368            ops::Op::SetFillColorSpace(name.into()),
369            ops::Op::SetFillColorComponents(color.values()),
370        )
371    }
372
373    /// Set stroke color using a Lab color space registered under a
374    /// caller-supplied resource name. See [`Self::set_fill_color_lab_named`].
375    pub fn set_stroke_color_lab_named(
376        &mut self,
377        name: impl Into<String>,
378        color: LabColor,
379    ) -> &mut Self {
380        self.push_color_space_and_components(
381            ops::Op::SetStrokeColorSpace(name.into()),
382            ops::Op::SetStrokeColorComponents(color.values()),
383        )
384    }
385
386    /// Set fill color using calibrated color space, referencing the default
387    /// `CalGray1`/`CalRGB1` resource slot.
388    ///
389    /// Delegates to [`Self::set_fill_color_calibrated_named`] with the default
390    /// name; behaviour is unchanged for existing callers.
391    pub fn set_fill_color_calibrated(&mut self, color: CalibratedColor) -> &mut Self {
392        let cs_name = match &color {
393            CalibratedColor::Gray(_, _) => "CalGray1",
394            CalibratedColor::Rgb(_, _) => "CalRGB1",
395        };
396        self.set_fill_color_calibrated_named(cs_name, color)
397    }
398
399    /// Set stroke color using calibrated color space, referencing the default
400    /// `CalGray1`/`CalRGB1` resource slot.
401    ///
402    /// Delegates to [`Self::set_stroke_color_calibrated_named`] with the
403    /// default name; behaviour is unchanged for existing callers.
404    pub fn set_stroke_color_calibrated(&mut self, color: CalibratedColor) -> &mut Self {
405        let cs_name = match &color {
406            CalibratedColor::Gray(_, _) => "CalGray1",
407            CalibratedColor::Rgb(_, _) => "CalRGB1",
408        };
409        self.set_stroke_color_calibrated_named(cs_name, color)
410    }
411
412    /// Set fill color using Lab color space, referencing the default `Lab1`
413    /// resource slot.
414    ///
415    /// Delegates to [`Self::set_fill_color_lab_named`] with the default name;
416    /// behaviour is unchanged for existing callers.
417    pub fn set_fill_color_lab(&mut self, color: LabColor) -> &mut Self {
418        self.set_fill_color_lab_named("Lab1", color)
419    }
420
421    /// Set stroke color using Lab color space, referencing the default `Lab1`
422    /// resource slot.
423    ///
424    /// Delegates to [`Self::set_stroke_color_lab_named`] with the default name;
425    /// behaviour is unchanged for existing callers.
426    pub fn set_stroke_color_lab(&mut self, color: LabColor) -> &mut Self {
427        self.set_stroke_color_lab_named("Lab1", color)
428    }
429
430    pub fn set_line_width(&mut self, width: f64) -> &mut Self {
431        self.line_width = width;
432        self.operations.push(ops::Op::SetLineWidth(width));
433        self
434    }
435
436    pub fn set_line_cap(&mut self, cap: LineCap) -> &mut Self {
437        self.current_line_cap = cap;
438        self.operations.push(ops::Op::SetLineCap(cap as u8));
439        self
440    }
441
442    pub fn set_line_join(&mut self, join: LineJoin) -> &mut Self {
443        self.current_line_join = join;
444        self.operations.push(ops::Op::SetLineJoin(join as u8));
445        self
446    }
447
448    /// Set the opacity for both fill and stroke operations (0.0 to 1.0)
449    pub fn set_opacity(&mut self, opacity: f64) -> &mut Self {
450        let opacity = opacity.clamp(0.0, 1.0);
451        self.fill_opacity = opacity;
452        self.stroke_opacity = opacity;
453
454        // Create pending ExtGState if opacity is not 1.0
455        if opacity < 1.0 {
456            let mut state = ExtGState::new();
457            state.alpha_fill = Some(opacity);
458            state.alpha_stroke = Some(opacity);
459            self.pending_extgstate = Some(state);
460        }
461
462        self
463    }
464
465    /// Set the fill opacity (0.0 to 1.0)
466    pub fn set_fill_opacity(&mut self, opacity: f64) -> &mut Self {
467        self.fill_opacity = opacity.clamp(0.0, 1.0);
468
469        // Update or create pending ExtGState
470        if opacity < 1.0 {
471            if let Some(ref mut state) = self.pending_extgstate {
472                state.alpha_fill = Some(opacity);
473            } else {
474                let mut state = ExtGState::new();
475                state.alpha_fill = Some(opacity);
476                self.pending_extgstate = Some(state);
477            }
478        }
479
480        self
481    }
482
483    /// Set the stroke opacity (0.0 to 1.0)
484    pub fn set_stroke_opacity(&mut self, opacity: f64) -> &mut Self {
485        self.stroke_opacity = opacity.clamp(0.0, 1.0);
486
487        // Update or create pending ExtGState
488        if opacity < 1.0 {
489            if let Some(ref mut state) = self.pending_extgstate {
490                state.alpha_stroke = Some(opacity);
491            } else {
492                let mut state = ExtGState::new();
493                state.alpha_stroke = Some(opacity);
494                self.pending_extgstate = Some(state);
495            }
496        }
497
498        self
499    }
500
501    pub fn save_state(&mut self) -> &mut Self {
502        self.operations.push(ops::Op::SaveState);
503        self.save_clipping_state();
504        // Save color + font state
505        self.state_stack.push(GraphicsState {
506            fill_color: self.current_color,
507            stroke_color: self.stroke_color,
508            font_name: self.current_font_name.clone(),
509            font_size: self.current_font_size,
510            is_custom_font: self.is_custom_font,
511        });
512        self
513    }
514
515    pub fn restore_state(&mut self) -> &mut Self {
516        self.operations.push(ops::Op::RestoreState);
517        self.restore_clipping_state();
518        // Restore color + font state
519        if let Some(state) = self.state_stack.pop() {
520            self.current_color = state.fill_color;
521            self.stroke_color = state.stroke_color;
522            self.current_font_name = state.font_name;
523            self.current_font_size = state.font_size;
524            self.is_custom_font = state.is_custom_font;
525        }
526        self
527    }
528
529    /// Begin a transparency group
530    /// ISO 32000-1:2008 Section 11.4
531    pub fn begin_transparency_group(&mut self, group: TransparencyGroup) -> &mut Self {
532        // Save current state
533        self.save_state();
534
535        // Mark beginning of transparency group with special comment
536        self.operations
537            .push(ops::Op::Comment("Begin Transparency Group".to_string()));
538
539        // Apply group settings via ExtGState
540        let mut extgstate = ExtGState::new();
541        extgstate = extgstate.with_blend_mode(group.blend_mode.clone());
542        extgstate.alpha_fill = Some(group.opacity as f64);
543        extgstate.alpha_stroke = Some(group.opacity as f64);
544
545        // Apply the ExtGState
546        self.pending_extgstate = Some(extgstate);
547        let _ = self.apply_pending_extgstate();
548
549        // Push group state onto the stack. Pre-2.7.0 we also serialised
550        // the entire ops buffer into a `saved_state` snapshot here, but
551        // the snapshot was never consumed — both fields were dead code.
552        // Removed in v2.7.0 (review finding).
553        self.transparency_stack
554            .push(TransparencyGroupState::new(group));
555
556        self
557    }
558
559    /// End a transparency group
560    pub fn end_transparency_group(&mut self) -> &mut Self {
561        if let Some(_group_state) = self.transparency_stack.pop() {
562            // Mark end of transparency group
563            self.operations
564                .push(ops::Op::Comment("End Transparency Group".to_string()));
565
566            // Restore state
567            self.restore_state();
568        }
569        self
570    }
571
572    /// Check if we're currently inside a transparency group
573    pub fn in_transparency_group(&self) -> bool {
574        !self.transparency_stack.is_empty()
575    }
576
577    /// Get the current transparency group (if any)
578    pub fn current_transparency_group(&self) -> Option<&TransparencyGroup> {
579        self.transparency_stack.last().map(|state| &state.group)
580    }
581
582    pub fn translate(&mut self, tx: f64, ty: f64) -> &mut Self {
583        self.operations.push(ops::Op::Cm {
584            a: 1.0,
585            b: 0.0,
586            c: 0.0,
587            d: 1.0,
588            e: tx,
589            f: ty,
590        });
591        self
592    }
593
594    pub fn scale(&mut self, sx: f64, sy: f64) -> &mut Self {
595        self.operations.push(ops::Op::Cm {
596            a: sx,
597            b: 0.0,
598            c: 0.0,
599            d: sy,
600            e: 0.0,
601            f: 0.0,
602        });
603        self
604    }
605
606    pub fn rotate(&mut self, angle: f64) -> &mut Self {
607        let cos = angle.cos();
608        let sin = angle.sin();
609        // Rotation historically used `{:.6}` precision; the IR uses `{:.2}`
610        // throughout the v2.7.0 refactor for consistency. The behavioural
611        // change is documented in CHANGELOG (2.7.0 cm matrix format).
612        self.operations.push(ops::Op::Cm {
613            a: cos,
614            b: sin,
615            c: -sin,
616            d: cos,
617            e: 0.0,
618            f: 0.0,
619        });
620        self
621    }
622
623    pub fn transform(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) -> &mut Self {
624        self.operations.push(ops::Op::Cm { a, b, c, d, e, f });
625        self
626    }
627
628    pub fn rectangle(&mut self, x: f64, y: f64, width: f64, height: f64) -> &mut Self {
629        self.rect(x, y, width, height)
630    }
631
632    pub fn draw_image(
633        &mut self,
634        image_name: impl Into<String>,
635        x: f64,
636        y: f64,
637        width: f64,
638        height: f64,
639    ) -> &mut Self {
640        // Save graphics state
641        self.save_state();
642
643        // Set up transformation matrix for image placement
644        // PDF coordinate system has origin at bottom-left, so we need to translate and scale
645        self.operations.push(ops::Op::Cm {
646            a: width,
647            b: 0.0,
648            c: 0.0,
649            d: height,
650            e: x,
651            f: y,
652        });
653
654        // Draw the image XObject
655        self.operations
656            .push(ops::Op::InvokeXObject(image_name.into()));
657
658        // Restore graphics state
659        self.restore_state();
660
661        self
662    }
663
664    /// Draw an image with transparency support (soft mask)
665    /// This method handles images with alpha channels or soft masks
666    pub fn draw_image_with_transparency(
667        &mut self,
668        image_name: impl Into<String>,
669        x: f64,
670        y: f64,
671        width: f64,
672        height: f64,
673        mask_name: Option<&str>,
674    ) -> &mut Self {
675        // Save graphics state
676        self.save_state();
677
678        // If we have a mask, we need to set up an ExtGState with SMask
679        if let Some(mask) = mask_name {
680            // Create an ExtGState for the soft mask
681            let mut extgstate = ExtGState::new();
682            extgstate.set_soft_mask_name(mask.to_string());
683
684            // Register and apply the ExtGState
685            let gs_name = self
686                .extgstate_manager
687                .add_state(extgstate)
688                .unwrap_or_else(|_| "GS1".to_string());
689            self.operations.push(ops::Op::SetExtGState(gs_name));
690        }
691
692        // Set up transformation matrix for image placement
693        self.operations.push(ops::Op::Cm {
694            a: width,
695            b: 0.0,
696            c: 0.0,
697            d: height,
698            e: x,
699            f: y,
700        });
701
702        // Draw the image XObject
703        self.operations
704            .push(ops::Op::InvokeXObject(image_name.into()));
705
706        // If we had a mask, reset the soft mask to None
707        if mask_name.is_some() {
708            // Create an ExtGState that removes the soft mask
709            let mut reset_extgstate = ExtGState::new();
710            reset_extgstate.set_soft_mask_none();
711
712            let gs_name = self
713                .extgstate_manager
714                .add_state(reset_extgstate)
715                .unwrap_or_else(|_| "GS2".to_string());
716            self.operations.push(ops::Op::SetExtGState(gs_name));
717        }
718
719        // Restore graphics state
720        self.restore_state();
721
722        self
723    }
724
725    fn apply_stroke_color(&mut self) {
726        // Single source of truth for stroke-colour emission across
727        // `TextContext`, `TextFlowContext`, and `GraphicsContext` — see
728        // `graphics::color::write_stroke_color` (issues #220 + #221).
729        // After the IR migration the operator is pushed as `Op::SetStrokeColor`
730        // and `serialize_ops` delegates to `write_stroke_color_bytes`.
731        self.operations
732            .push(ops::Op::SetStrokeColor(self.stroke_color));
733    }
734
735    fn apply_fill_color(&mut self) {
736        // Single source of truth for fill-colour emission. See sibling
737        // `apply_stroke_color`. The IR delegates emission to
738        // `write_fill_color_bytes`, preserving the NaN/inf sanitisation
739        // and device-space selection from 2.6.0.
740        self.operations
741            .push(ops::Op::SetFillColor(self.current_color));
742    }
743
744    pub(crate) fn generate_operations(&self) -> Result<Vec<u8>> {
745        let mut buf = Vec::new();
746        ops::serialize_ops(&mut buf, &self.operations);
747        Ok(buf)
748    }
749
750    /// Take ownership of the accumulated `Op` buffer, leaving an empty
751    /// `Vec` in its place. Used by `Page` to flush the graphics buffer
752    /// into a unified content stream when the caller switches contexts
753    /// (issue #227 — preserves PDF painter-model call order across
754    /// `Page::graphics()` / `Page::text()` switches).
755    ///
756    /// State fields (`current_color`, `line_width`, …) are unaffected so
757    /// the next chain of calls on this context resumes with the same
758    /// graphics state.
759    pub(crate) fn drain_ops(&mut self) -> Vec<ops::Op> {
760        std::mem::take(&mut self.operations)
761    }
762
763    /// Read-only access to the operation list (used by `Page` to peek
764    /// at whether a flush is required without taking the buffer).
765    pub(crate) fn ops_slice(&self) -> &[ops::Op] {
766        &self.operations
767    }
768
769    /// Check if transparency is used (opacity != 1.0)
770    pub fn uses_transparency(&self) -> bool {
771        self.fill_opacity < 1.0 || self.stroke_opacity < 1.0
772    }
773
774    /// Generate the graphics state dictionary for transparency
775    pub fn generate_graphics_state_dict(&self) -> Option<String> {
776        if !self.uses_transparency() {
777            return None;
778        }
779
780        let mut dict = String::from("<< /Type /ExtGState");
781
782        if self.fill_opacity < 1.0 {
783            write!(&mut dict, " /ca {:.3}", self.fill_opacity)
784                .expect("Writing to string should never fail");
785        }
786
787        if self.stroke_opacity < 1.0 {
788            write!(&mut dict, " /CA {:.3}", self.stroke_opacity)
789                .expect("Writing to string should never fail");
790        }
791
792        dict.push_str(" >>");
793        Some(dict)
794    }
795
796    /// Get the current fill color
797    pub fn fill_color(&self) -> Color {
798        self.current_color
799    }
800
801    /// Get the current stroke color
802    pub fn stroke_color(&self) -> Color {
803        self.stroke_color
804    }
805
806    /// Get the current line width
807    pub fn line_width(&self) -> f64 {
808        self.line_width
809    }
810
811    /// Get the current fill opacity
812    pub fn fill_opacity(&self) -> f64 {
813        self.fill_opacity
814    }
815
816    /// Get the current stroke opacity
817    pub fn stroke_opacity(&self) -> f64 {
818        self.stroke_opacity
819    }
820
821    /// Get the operations as a serialised PDF content-stream `String`.
822    ///
823    /// Pre-2.7.0 this returned `&str`. The IR migration replaced the
824    /// internal `String` buffer with a typed `Vec<Op>`, so the legacy
825    /// borrow is materialised on demand. Internal callers prefer
826    /// `generate_operations()` which returns the byte buffer directly.
827    pub fn operations(&self) -> String {
828        ops::ops_to_string(&self.operations)
829    }
830
831    /// Get the operations as a serialised content-stream `String` (alias
832    /// retained for legacy tests; mirrors `operations()`).
833    pub fn get_operations(&self) -> String {
834        ops::ops_to_string(&self.operations)
835    }
836
837    /// Clear all operations
838    pub fn clear(&mut self) {
839        self.operations.clear();
840    }
841
842    /// Begin a text object
843    pub fn begin_text(&mut self) -> &mut Self {
844        self.operations.push(ops::Op::BeginText);
845        self
846    }
847
848    /// End a text object
849    pub fn end_text(&mut self) -> &mut Self {
850        self.operations.push(ops::Op::EndText);
851        self
852    }
853
854    /// Set font and size
855    pub fn set_font(&mut self, font: Font, size: f64) -> &mut Self {
856        self.operations.push(ops::Op::SetFont {
857            name: font.pdf_name(),
858            size,
859        });
860
861        // Track font name, size, and type for Unicode detection and proper font handling
862        match &font {
863            Font::Custom(name) => {
864                self.current_font_name = Some(Arc::from(name.as_str()));
865                self.current_font_size = size;
866                self.is_custom_font = true;
867            }
868            _ => {
869                self.current_font_name = Some(Arc::from(font.pdf_name().as_str()));
870                self.current_font_size = size;
871                self.is_custom_font = false;
872            }
873        }
874
875        self
876    }
877
878    /// Set text position
879    pub fn set_text_position(&mut self, x: f64, y: f64) -> &mut Self {
880        self.operations.push(ops::Op::SetTextPosition { x, y });
881        self
882    }
883
884    /// Show text
885    ///
886    /// For custom (Type0/CID) fonts, text is encoded as Unicode code points (CIDs).
887    /// BMP characters (U+0000..U+FFFF) are written as 4-hex-digit values.
888    /// Supplementary plane characters (U+10000..U+10FFFF) use UTF-16BE surrogate pairs.
889    /// For standard fonts, text is encoded as literal PDF strings.
890    pub fn show_text(&mut self, text: &str) -> Result<&mut Self> {
891        // Track used characters for font subsetting, bucketed by font name
892        // (issue #204). Builtin fonts skip tracking — subsetting only
893        // applies to custom Type0/CID fonts.
894        self.record_used_chars(text);
895
896        if self.is_custom_font {
897            // For custom fonts (CJK/Type0), encode as hex string with Unicode code points as CIDs
898            let mut hex = String::new();
899            for ch in text.chars() {
900                encode_char_as_cid(ch, &mut hex);
901            }
902            self.operations.push(ops::Op::ShowTextHex(hex.into_bytes()));
903        } else {
904            // For standard fonts, escape special characters in PDF literal string
905            let mut escaped = String::new();
906            for ch in text.chars() {
907                match ch {
908                    '(' => escaped.push_str("\\("),
909                    ')' => escaped.push_str("\\)"),
910                    '\\' => escaped.push_str("\\\\"),
911                    '\n' => escaped.push_str("\\n"),
912                    '\r' => escaped.push_str("\\r"),
913                    '\t' => escaped.push_str("\\t"),
914                    _ => escaped.push(ch),
915                }
916            }
917            self.operations
918                .push(ops::Op::ShowText(escaped.into_bytes()));
919        }
920        Ok(self)
921    }
922
923    /// Set word spacing for text justification
924    pub fn set_word_spacing(&mut self, spacing: f64) -> &mut Self {
925        self.operations.push(ops::Op::SetWordSpacing(spacing));
926        self
927    }
928
929    /// Set character spacing
930    pub fn set_character_spacing(&mut self, spacing: f64) -> &mut Self {
931        self.operations.push(ops::Op::SetCharSpacing(spacing));
932        self
933    }
934
935    /// Show justified text with automatic word spacing calculation
936    pub fn show_justified_text(&mut self, text: &str, target_width: f64) -> Result<&mut Self> {
937        // Split text into words
938        let words: Vec<&str> = text.split_whitespace().collect();
939        if words.len() <= 1 {
940            // Can't justify single word or empty text
941            return self.show_text(text);
942        }
943
944        // Calculate natural width of text without extra spacing
945        let text_without_spaces = words.join("");
946        let natural_text_width = self.estimate_text_width_simple(&text_without_spaces);
947        let space_width = self.estimate_text_width_simple(" ");
948        let natural_width = natural_text_width + (space_width * (words.len() - 1) as f64);
949
950        // Calculate extra spacing needed per word gap
951        let extra_space_needed = target_width - natural_width;
952        let word_gaps = (words.len() - 1) as f64;
953
954        if word_gaps > 0.0 && extra_space_needed > 0.0 {
955            let extra_word_spacing = extra_space_needed / word_gaps;
956
957            // Set word spacing
958            self.set_word_spacing(extra_word_spacing);
959
960            // Show text (spaces will be expanded automatically)
961            self.show_text(text)?;
962
963            // Reset word spacing to default
964            self.set_word_spacing(0.0);
965        } else {
966            // Fallback to normal text display
967            self.show_text(text)?;
968        }
969
970        Ok(self)
971    }
972
973    /// Simple text width estimation (placeholder implementation)
974    fn estimate_text_width_simple(&self, text: &str) -> f64 {
975        // This is a simplified estimation. In a full implementation,
976        // you would use actual font metrics.
977        let font_size = self.current_font_size;
978        text.len() as f64 * font_size * 0.6 // Approximate width factor
979    }
980
981    /// Render a table
982    pub fn render_table(&mut self, table: &Table) -> Result<()> {
983        table.render(self)
984    }
985
986    /// Render a list
987    pub fn render_list(&mut self, list: &ListElement) -> Result<()> {
988        match list {
989            ListElement::Ordered(ordered) => ordered.render(self),
990            ListElement::Unordered(unordered) => unordered.render(self),
991        }
992    }
993
994    /// Render column layout
995    pub fn render_column_layout(
996        &mut self,
997        layout: &ColumnLayout,
998        content: &ColumnContent,
999        x: f64,
1000        y: f64,
1001        height: f64,
1002    ) -> Result<()> {
1003        layout.render(self, content, x, y, height)
1004    }
1005
1006    // Extended Graphics State methods
1007
1008    /// Set line dash pattern
1009    pub fn set_line_dash_pattern(&mut self, pattern: LineDashPattern) -> &mut Self {
1010        self.current_dash_pattern = Some(pattern.clone());
1011        self.operations
1012            .push(ops::Op::SetDashPatternRaw(pattern.to_pdf_string()));
1013        self
1014    }
1015
1016    /// Set line dash pattern to solid (no dashes)
1017    pub fn set_line_solid(&mut self) -> &mut Self {
1018        self.current_dash_pattern = None;
1019        self.operations
1020            .push(ops::Op::SetDashPatternRaw("[] 0".to_string()));
1021        self
1022    }
1023
1024    /// Set miter limit
1025    pub fn set_miter_limit(&mut self, limit: f64) -> &mut Self {
1026        self.current_miter_limit = limit.max(1.0);
1027        self.operations
1028            .push(ops::Op::SetMiterLimit(self.current_miter_limit));
1029        self
1030    }
1031
1032    /// Set rendering intent
1033    pub fn set_rendering_intent(&mut self, intent: RenderingIntent) -> &mut Self {
1034        self.current_rendering_intent = intent;
1035        self.operations
1036            .push(ops::Op::SetRenderingIntent(intent.pdf_name().to_string()));
1037        self
1038    }
1039
1040    /// Set flatness tolerance
1041    pub fn set_flatness(&mut self, flatness: f64) -> &mut Self {
1042        self.current_flatness = flatness.clamp(0.0, 100.0);
1043        self.operations
1044            .push(ops::Op::SetFlatness(self.current_flatness));
1045        self
1046    }
1047
1048    /// Apply an ExtGState dictionary immediately
1049    pub fn apply_extgstate(&mut self, state: ExtGState) -> Result<&mut Self> {
1050        let state_name = self.extgstate_manager.add_state(state)?;
1051        self.operations.push(ops::Op::SetExtGState(state_name));
1052        Ok(self)
1053    }
1054
1055    /// Store an ExtGState to be applied before the next drawing operation
1056    #[allow(dead_code)]
1057    fn set_pending_extgstate(&mut self, state: ExtGState) {
1058        self.pending_extgstate = Some(state);
1059    }
1060
1061    /// Apply any pending ExtGState before drawing
1062    fn apply_pending_extgstate(&mut self) -> Result<()> {
1063        if let Some(state) = self.pending_extgstate.take() {
1064            let state_name = self.extgstate_manager.add_state(state)?;
1065            self.operations.push(ops::Op::SetExtGState(state_name));
1066        }
1067        Ok(())
1068    }
1069
1070    /// Create and apply a custom ExtGState
1071    pub fn with_extgstate<F>(&mut self, builder: F) -> Result<&mut Self>
1072    where
1073        F: FnOnce(ExtGState) -> ExtGState,
1074    {
1075        let state = builder(ExtGState::new());
1076        self.apply_extgstate(state)
1077    }
1078
1079    /// Set blend mode for transparency
1080    pub fn set_blend_mode(&mut self, mode: BlendMode) -> Result<&mut Self> {
1081        let state = ExtGState::new().with_blend_mode(mode);
1082        self.apply_extgstate(state)
1083    }
1084
1085    /// Set alpha for both stroke and fill operations
1086    pub fn set_alpha(&mut self, alpha: f64) -> Result<&mut Self> {
1087        let state = ExtGState::new().with_alpha(alpha);
1088        self.apply_extgstate(state)
1089    }
1090
1091    /// Set alpha for stroke operations only
1092    pub fn set_alpha_stroke(&mut self, alpha: f64) -> Result<&mut Self> {
1093        let state = ExtGState::new().with_alpha_stroke(alpha);
1094        self.apply_extgstate(state)
1095    }
1096
1097    /// Set alpha for fill operations only
1098    pub fn set_alpha_fill(&mut self, alpha: f64) -> Result<&mut Self> {
1099        let state = ExtGState::new().with_alpha_fill(alpha);
1100        self.apply_extgstate(state)
1101    }
1102
1103    /// Set overprint for stroke operations
1104    pub fn set_overprint_stroke(&mut self, overprint: bool) -> Result<&mut Self> {
1105        let state = ExtGState::new().with_overprint_stroke(overprint);
1106        self.apply_extgstate(state)
1107    }
1108
1109    /// Set overprint for fill operations
1110    pub fn set_overprint_fill(&mut self, overprint: bool) -> Result<&mut Self> {
1111        let state = ExtGState::new().with_overprint_fill(overprint);
1112        self.apply_extgstate(state)
1113    }
1114
1115    /// Set stroke adjustment
1116    pub fn set_stroke_adjustment(&mut self, adjustment: bool) -> Result<&mut Self> {
1117        let state = ExtGState::new().with_stroke_adjustment(adjustment);
1118        self.apply_extgstate(state)
1119    }
1120
1121    /// Set smoothness tolerance
1122    pub fn set_smoothness(&mut self, smoothness: f64) -> Result<&mut Self> {
1123        self.current_smoothness = smoothness.clamp(0.0, 1.0);
1124        let state = ExtGState::new().with_smoothness(self.current_smoothness);
1125        self.apply_extgstate(state)
1126    }
1127
1128    // Getters for extended graphics state
1129
1130    /// Get current line dash pattern
1131    pub fn line_dash_pattern(&self) -> Option<&LineDashPattern> {
1132        self.current_dash_pattern.as_ref()
1133    }
1134
1135    /// Get current miter limit
1136    pub fn miter_limit(&self) -> f64 {
1137        self.current_miter_limit
1138    }
1139
1140    /// Get current line cap
1141    pub fn line_cap(&self) -> LineCap {
1142        self.current_line_cap
1143    }
1144
1145    /// Get current line join
1146    pub fn line_join(&self) -> LineJoin {
1147        self.current_line_join
1148    }
1149
1150    /// Get current rendering intent
1151    pub fn rendering_intent(&self) -> RenderingIntent {
1152        self.current_rendering_intent
1153    }
1154
1155    /// Get current flatness tolerance
1156    pub fn flatness(&self) -> f64 {
1157        self.current_flatness
1158    }
1159
1160    /// Get current smoothness tolerance
1161    pub fn smoothness(&self) -> f64 {
1162        self.current_smoothness
1163    }
1164
1165    /// Get the ExtGState manager (for advanced usage)
1166    pub fn extgstate_manager(&self) -> &ExtGStateManager {
1167        &self.extgstate_manager
1168    }
1169
1170    /// Get mutable ExtGState manager (for advanced usage)
1171    pub fn extgstate_manager_mut(&mut self) -> &mut ExtGStateManager {
1172        &mut self.extgstate_manager
1173    }
1174
1175    /// Generate ExtGState resource dictionary for PDF
1176    pub fn generate_extgstate_resources(&self) -> Result<String> {
1177        self.extgstate_manager.to_resource_dictionary()
1178    }
1179
1180    /// Check if any extended graphics states are defined
1181    pub fn has_extgstates(&self) -> bool {
1182        self.extgstate_manager.count() > 0
1183    }
1184
1185    /// Add a command to the operations.
1186    ///
1187    /// Untyped escape hatch — bytes are emitted verbatim with a trailing
1188    /// newline. Used by callers that need to inject operators not yet
1189    /// modelled as `Op` variants. New code should prefer the typed
1190    /// methods on `GraphicsContext`.
1191    pub fn add_command(&mut self, command: &str) {
1192        let mut bytes = command.as_bytes().to_vec();
1193        bytes.push(b'\n');
1194        self.operations.push(ops::Op::Raw(bytes));
1195    }
1196
1197    /// Create clipping path from current path using non-zero winding rule
1198    pub fn clip(&mut self) -> &mut Self {
1199        self.operations.push(ops::Op::ClipNonZero);
1200        self
1201    }
1202
1203    /// End the current path without filling or stroking (`n`).
1204    ///
1205    /// Required to terminate a clipping path: the canonical sequence is
1206    /// `<path> W n` (ISO 32000-1 §8.5.4). Use after [`clip`](Self::clip)
1207    /// before painting a shading into the clipped region.
1208    pub fn end_path(&mut self) -> &mut Self {
1209        // `n` is a path-painting (no-op) operator, not a colour-painting one,
1210        // so no pending-ExtGState flush is needed here (matches `clip`).
1211        self.operations.push(ops::Op::EndPath);
1212        self
1213    }
1214
1215    /// Create clipping path from current path using even-odd rule
1216    pub fn clip_even_odd(&mut self) -> &mut Self {
1217        self.operations.push(ops::Op::ClipEvenOdd);
1218        self
1219    }
1220
1221    /// Create clipping path and stroke it
1222    pub fn clip_stroke(&mut self) -> &mut Self {
1223        self.apply_stroke_color();
1224        self.operations.push(ops::Op::ClipStroke);
1225        self
1226    }
1227
1228    /// Set a custom clipping path
1229    pub fn set_clipping_path(&mut self, path: ClippingPath) -> Result<&mut Self> {
1230        let ops_str = path.to_pdf_operations()?;
1231        self.operations.push(ops::Op::Raw(ops_str.into_bytes()));
1232        self.clipping_region.set_clip(path);
1233        Ok(self)
1234    }
1235
1236    /// Clear the current clipping path
1237    pub fn clear_clipping(&mut self) -> &mut Self {
1238        self.clipping_region.clear_clip();
1239        self
1240    }
1241
1242    /// Save the current clipping state (called automatically by save_state)
1243    fn save_clipping_state(&mut self) {
1244        self.clipping_region.save();
1245    }
1246
1247    /// Restore the previous clipping state (called automatically by restore_state)
1248    fn restore_clipping_state(&mut self) {
1249        self.clipping_region.restore();
1250    }
1251
1252    /// Create a rectangular clipping region
1253    pub fn clip_rect(&mut self, x: f64, y: f64, width: f64, height: f64) -> Result<&mut Self> {
1254        let path = ClippingPath::rect(x, y, width, height);
1255        self.set_clipping_path(path)
1256    }
1257
1258    /// Create a circular clipping region
1259    pub fn clip_circle(&mut self, cx: f64, cy: f64, radius: f64) -> Result<&mut Self> {
1260        let path = ClippingPath::circle(cx, cy, radius);
1261        self.set_clipping_path(path)
1262    }
1263
1264    /// Create an elliptical clipping region
1265    pub fn clip_ellipse(&mut self, cx: f64, cy: f64, rx: f64, ry: f64) -> Result<&mut Self> {
1266        let path = ClippingPath::ellipse(cx, cy, rx, ry);
1267        self.set_clipping_path(path)
1268    }
1269
1270    /// Check if a clipping path is active
1271    pub fn has_clipping(&self) -> bool {
1272        self.clipping_region.has_clip()
1273    }
1274
1275    /// Get the current clipping path
1276    pub fn clipping_path(&self) -> Option<&ClippingPath> {
1277        self.clipping_region.current()
1278    }
1279
1280    /// Set the font manager for custom fonts
1281    pub fn set_font_manager(&mut self, font_manager: Arc<FontManager>) -> &mut Self {
1282        self.font_manager = Some(font_manager);
1283        self
1284    }
1285
1286    /// Set the current font to a custom font
1287    pub fn set_custom_font(&mut self, font_name: &str, size: f64) -> &mut Self {
1288        // Emit Tf operator to the content stream (consistent with set_font)
1289        self.operations.push(ops::Op::SetFont {
1290            name: font_name.to_string(),
1291            size,
1292        });
1293
1294        self.current_font_name = Some(Arc::from(font_name));
1295        self.current_font_size = size;
1296        self.is_custom_font = true;
1297
1298        // Try to get the glyph mapping from the font manager
1299        if let Some(ref font_manager) = self.font_manager {
1300            if let Some(mapping) = font_manager.get_font_glyph_mapping(font_name) {
1301                self.glyph_mapping = Some(mapping);
1302            }
1303        }
1304
1305        self
1306    }
1307
1308    /// Set the glyph mapping for Unicode fonts (Unicode -> GlyphID)
1309    pub fn set_glyph_mapping(&mut self, mapping: HashMap<u32, u16>) -> &mut Self {
1310        self.glyph_mapping = Some(mapping);
1311        self
1312    }
1313
1314    /// Draw text at the specified position with automatic encoding detection
1315    pub fn draw_text(&mut self, text: &str, x: f64, y: f64) -> Result<&mut Self> {
1316        // Track used characters for font subsetting, bucketed by font name
1317        // (issue #204).
1318        self.record_used_chars(text);
1319
1320        // Detect if text needs Unicode encoding: custom fonts always use hex,
1321        // and text with non-Latin-1 characters also needs Unicode encoding
1322        let needs_unicode = self.is_custom_font || text.chars().any(|c| c as u32 > 255);
1323
1324        // Use appropriate encoding based on content and font type
1325        if needs_unicode {
1326            self.draw_with_unicode_encoding(text, x, y)
1327        } else {
1328            self.draw_with_simple_encoding(text, x, y)
1329        }
1330    }
1331
1332    /// Internal: Draw text with simple encoding (WinAnsiEncoding for standard fonts)
1333    fn draw_with_simple_encoding(&mut self, text: &str, x: f64, y: f64) -> Result<&mut Self> {
1334        // Check if text contains characters outside Latin-1
1335        let has_unicode = text.chars().any(|c| c as u32 > 255);
1336
1337        if has_unicode {
1338            tracing::debug!("Warning: Text contains Unicode characters but using Latin-1 font. Characters will be replaced with '?'");
1339        }
1340
1341        self.operations.push(ops::Op::BeginText);
1342        self.apply_fill_color();
1343        self.push_active_font();
1344        self.operations.push(ops::Op::SetTextPosition { x, y });
1345
1346        // Encode text as a literal string (parentheses, WinAnsi octal escapes
1347        // for 128–255, '?' fallback for code points beyond Latin-1).
1348        let mut buf = String::new();
1349        for ch in text.chars() {
1350            let code = ch as u32;
1351            if code <= 127 {
1352                match ch {
1353                    '(' => buf.push_str("\\("),
1354                    ')' => buf.push_str("\\)"),
1355                    '\\' => buf.push_str("\\\\"),
1356                    '\n' => buf.push_str("\\n"),
1357                    '\r' => buf.push_str("\\r"),
1358                    '\t' => buf.push_str("\\t"),
1359                    _ => buf.push(ch),
1360                }
1361            } else if code <= 255 {
1362                use std::fmt::Write as _;
1363                write!(&mut buf, "\\{code:03o}").expect("write to String never fails");
1364            } else {
1365                buf.push('?');
1366            }
1367        }
1368        self.operations.push(ops::Op::ShowText(buf.into_bytes()));
1369        self.operations.push(ops::Op::EndText);
1370
1371        Ok(self)
1372    }
1373
1374    /// Internal: Draw text with Unicode encoding (Type0/CID)
1375    fn draw_with_unicode_encoding(&mut self, text: &str, x: f64, y: f64) -> Result<&mut Self> {
1376        self.operations.push(ops::Op::BeginText);
1377        self.apply_fill_color();
1378        self.push_active_font();
1379        self.operations.push(ops::Op::SetTextPosition { x, y });
1380
1381        let mut hex = String::new();
1382        for ch in text.chars() {
1383            encode_char_as_cid(ch, &mut hex);
1384        }
1385        self.operations.push(ops::Op::ShowTextHex(hex.into_bytes()));
1386        self.operations.push(ops::Op::EndText);
1387
1388        Ok(self)
1389    }
1390
1391    /// Push a `Tf` operator for the active font, falling back to
1392    /// `/Helvetica` at the current size when no font has been set.
1393    /// Shared by `draw_with_simple_encoding`, `draw_with_unicode_encoding`,
1394    /// and the deprecated `draw_text_*` aliases.
1395    fn push_active_font(&mut self) {
1396        let name = self
1397            .current_font_name
1398            .as_deref()
1399            .unwrap_or("Helvetica")
1400            .to_string();
1401        self.operations.push(ops::Op::SetFont {
1402            name,
1403            size: self.current_font_size,
1404        });
1405    }
1406
1407    /// Legacy: Draw text with hex encoding (kept for compatibility)
1408    #[deprecated(note = "Use draw_text() which automatically detects encoding")]
1409    pub fn draw_text_hex(&mut self, text: &str, x: f64, y: f64) -> Result<&mut Self> {
1410        self.operations.push(ops::Op::BeginText);
1411        self.apply_fill_color();
1412        self.push_active_font();
1413        self.operations.push(ops::Op::SetTextPosition { x, y });
1414
1415        let mut hex = String::new();
1416        for ch in text.chars() {
1417            use std::fmt::Write as _;
1418            if ch as u32 <= 255 {
1419                write!(&mut hex, "{:02X}", ch as u8).expect("write to String never fails");
1420            } else {
1421                hex.push_str("3F");
1422            }
1423        }
1424        self.operations.push(ops::Op::ShowTextHex(hex.into_bytes()));
1425        self.operations.push(ops::Op::EndText);
1426
1427        Ok(self)
1428    }
1429
1430    /// Legacy: Draw text with Type0 font encoding (kept for compatibility)
1431    #[deprecated(note = "Use draw_text() which automatically detects encoding")]
1432    pub fn draw_text_cid(&mut self, text: &str, x: f64, y: f64) -> Result<&mut Self> {
1433        use crate::fonts::needs_type0_font;
1434
1435        self.operations.push(ops::Op::BeginText);
1436        self.apply_fill_color();
1437        self.push_active_font();
1438        self.operations.push(ops::Op::SetTextPosition { x, y });
1439
1440        let mut hex = String::new();
1441        if needs_type0_font(text) {
1442            for ch in text.chars() {
1443                encode_char_as_cid(ch, &mut hex);
1444            }
1445        } else {
1446            for ch in text.chars() {
1447                use std::fmt::Write as _;
1448                if ch as u32 <= 255 {
1449                    write!(&mut hex, "{:02X}", ch as u8).expect("write to String never fails");
1450                } else {
1451                    hex.push_str("3F");
1452                }
1453            }
1454        }
1455        self.operations.push(ops::Op::ShowTextHex(hex.into_bytes()));
1456        self.operations.push(ops::Op::EndText);
1457
1458        Ok(self)
1459    }
1460
1461    /// Legacy: Draw text with UTF-16BE encoding (kept for compatibility)
1462    #[deprecated(note = "Use draw_text() which automatically detects encoding")]
1463    pub fn draw_text_unicode(&mut self, text: &str, x: f64, y: f64) -> Result<&mut Self> {
1464        self.operations.push(ops::Op::BeginText);
1465        self.apply_fill_color();
1466        self.push_active_font();
1467        self.operations.push(ops::Op::SetTextPosition { x, y });
1468
1469        let mut hex = String::new();
1470        let mut utf16_buffer = [0u16; 2];
1471        for ch in text.chars() {
1472            let encoded = ch.encode_utf16(&mut utf16_buffer);
1473            for unit in encoded {
1474                use std::fmt::Write as _;
1475                write!(&mut hex, "{:04X}", unit).expect("write to String never fails");
1476            }
1477        }
1478        self.operations.push(ops::Op::ShowTextHex(hex.into_bytes()));
1479        self.operations.push(ops::Op::EndText);
1480
1481        Ok(self)
1482    }
1483
1484    /// Record `text` as drawn with the currently-active font.
1485    ///
1486    /// Chars are bucketed under the font name (builtin or custom) so
1487    /// that the writer can subset each custom font with only its own
1488    /// characters (issue #204). When no font has been set yet the
1489    /// chars are bucketed under an empty-string sentinel — the writer
1490    /// iterates `custom_font_names()` when subsetting and that list
1491    /// never contains an empty name, so the sentinel is ignored by
1492    /// the writer but keeps the merged [`Self::get_used_characters`]
1493    /// accessor lossless for diagnostic callers.
1494    fn record_used_chars(&mut self, text: &str) {
1495        let bucket = self.current_font_name.as_deref().unwrap_or("").to_string();
1496        self.used_characters_by_font
1497            .entry(bucket)
1498            .or_default()
1499            .extend(text.chars());
1500    }
1501
1502    /// Get the characters used in this graphics context, merged across
1503    /// fonts. Test-only back-compat accessor; production callers go
1504    /// through [`GraphicsContext::get_used_characters_by_font`] so the
1505    /// writer can subset each custom font with only its own characters
1506    /// (issue #204).
1507    #[cfg(test)]
1508    pub(crate) fn get_used_characters(&self) -> Option<HashSet<char>> {
1509        let merged: HashSet<char> = self
1510            .used_characters_by_font
1511            .values()
1512            .flat_map(|s| s.iter().copied())
1513            .collect();
1514        if merged.is_empty() {
1515            None
1516        } else {
1517            Some(merged)
1518        }
1519    }
1520
1521    /// Get the per-font character map for font subsetting (issue #204).
1522    ///
1523    /// Keys are the registered custom-font names exactly as passed to
1524    /// `Document::add_font_from_bytes`. Builtin fonts never appear as
1525    /// keys because they don't need subsetting. A font name missing
1526    /// from the map means no content stream in this context drew any
1527    /// character with that font.
1528    pub(crate) fn get_used_characters_by_font(&self) -> &HashMap<String, HashSet<char>> {
1529        &self.used_characters_by_font
1530    }
1531
1532    /// Merge a per-font char map produced by an external content-stream
1533    /// builder (e.g. [`crate::layout::RichText::render_operations`])
1534    /// into this graphics context's accumulator. Issue #204 — callers
1535    /// of [`crate::Page::append_raw_content`] MUST report what they
1536    /// drew so the writer can subset each custom font correctly.
1537    pub(crate) fn merge_font_usage(&mut self, usage: &HashMap<String, HashSet<char>>) {
1538        for (name, chars) in usage {
1539            self.used_characters_by_font
1540                .entry(name.clone())
1541                .or_default()
1542                .extend(chars);
1543        }
1544    }
1545}
1546
1547#[cfg(test)]
1548mod tests {
1549    use super::*;
1550
1551    #[test]
1552    fn test_graphics_context_new() {
1553        let ctx = GraphicsContext::new();
1554        assert_eq!(ctx.fill_color(), Color::black());
1555        assert_eq!(ctx.stroke_color(), Color::black());
1556        assert_eq!(ctx.line_width(), 1.0);
1557        assert_eq!(ctx.fill_opacity(), 1.0);
1558        assert_eq!(ctx.stroke_opacity(), 1.0);
1559        assert!(ctx.operations().is_empty());
1560    }
1561
1562    #[test]
1563    fn test_graphics_context_default() {
1564        let ctx = GraphicsContext::default();
1565        assert_eq!(ctx.fill_color(), Color::black());
1566        assert_eq!(ctx.stroke_color(), Color::black());
1567        assert_eq!(ctx.line_width(), 1.0);
1568    }
1569
1570    #[test]
1571    fn test_move_to() {
1572        let mut ctx = GraphicsContext::new();
1573        ctx.move_to(10.0, 20.0);
1574        assert!(ctx.operations().contains("10.00 20.00 m\n"));
1575    }
1576
1577    #[test]
1578    fn test_line_to() {
1579        let mut ctx = GraphicsContext::new();
1580        ctx.line_to(30.0, 40.0);
1581        assert!(ctx.operations().contains("30.00 40.00 l\n"));
1582    }
1583
1584    #[test]
1585    fn test_curve_to() {
1586        let mut ctx = GraphicsContext::new();
1587        ctx.curve_to(10.0, 20.0, 30.0, 40.0, 50.0, 60.0);
1588        assert!(ctx
1589            .operations()
1590            .contains("10.00 20.00 30.00 40.00 50.00 60.00 c\n"));
1591    }
1592
1593    #[test]
1594    fn test_rect() {
1595        let mut ctx = GraphicsContext::new();
1596        ctx.rect(10.0, 20.0, 100.0, 50.0);
1597        assert!(ctx.operations().contains("10.00 20.00 100.00 50.00 re\n"));
1598    }
1599
1600    #[test]
1601    fn test_rectangle_alias() {
1602        let mut ctx = GraphicsContext::new();
1603        ctx.rectangle(10.0, 20.0, 100.0, 50.0);
1604        assert!(ctx.operations().contains("10.00 20.00 100.00 50.00 re\n"));
1605    }
1606
1607    #[test]
1608    fn test_circle() {
1609        let mut ctx = GraphicsContext::new();
1610        ctx.circle(50.0, 50.0, 25.0);
1611
1612        let ops = ctx.operations();
1613        // Check that it starts with move to radius point
1614        assert!(ops.contains("75.00 50.00 m\n"));
1615        // Check that it contains curve operations
1616        assert!(ops.contains(" c\n"));
1617        // Check that it closes the path
1618        assert!(ops.contains("h\n"));
1619    }
1620
1621    #[test]
1622    fn test_close_path() {
1623        let mut ctx = GraphicsContext::new();
1624        ctx.close_path();
1625        assert!(ctx.operations().contains("h\n"));
1626    }
1627
1628    #[test]
1629    fn test_stroke() {
1630        let mut ctx = GraphicsContext::new();
1631        ctx.set_stroke_color(Color::red());
1632        ctx.rect(0.0, 0.0, 10.0, 10.0);
1633        ctx.stroke();
1634
1635        let ops = ctx.operations();
1636        assert!(ops.contains("1.000 0.000 0.000 RG\n"));
1637        assert!(ops.contains("S\n"));
1638    }
1639
1640    #[test]
1641    fn test_fill() {
1642        let mut ctx = GraphicsContext::new();
1643        ctx.set_fill_color(Color::blue());
1644        ctx.rect(0.0, 0.0, 10.0, 10.0);
1645        ctx.fill();
1646
1647        let ops = ctx.operations();
1648        assert!(ops.contains("0.000 0.000 1.000 rg\n"));
1649        assert!(ops.contains("f\n"));
1650    }
1651
1652    #[test]
1653    fn test_fill_stroke() {
1654        let mut ctx = GraphicsContext::new();
1655        ctx.set_fill_color(Color::green());
1656        ctx.set_stroke_color(Color::red());
1657        ctx.rect(0.0, 0.0, 10.0, 10.0);
1658        ctx.fill_stroke();
1659
1660        let ops = ctx.operations();
1661        assert!(ops.contains("0.000 1.000 0.000 rg\n"));
1662        assert!(ops.contains("1.000 0.000 0.000 RG\n"));
1663        assert!(ops.contains("B\n"));
1664    }
1665
1666    #[test]
1667    fn test_set_stroke_color() {
1668        let mut ctx = GraphicsContext::new();
1669        ctx.set_stroke_color(Color::rgb(0.5, 0.6, 0.7));
1670        assert_eq!(ctx.stroke_color(), Color::Rgb(0.5, 0.6, 0.7));
1671    }
1672
1673    #[test]
1674    fn test_set_fill_color() {
1675        let mut ctx = GraphicsContext::new();
1676        ctx.set_fill_color(Color::gray(0.5));
1677        assert_eq!(ctx.fill_color(), Color::Gray(0.5));
1678    }
1679
1680    #[test]
1681    fn test_set_line_width() {
1682        let mut ctx = GraphicsContext::new();
1683        ctx.set_line_width(2.5);
1684        assert_eq!(ctx.line_width(), 2.5);
1685        assert!(ctx.operations().contains("2.50 w\n"));
1686    }
1687
1688    #[test]
1689    fn test_set_line_cap() {
1690        let mut ctx = GraphicsContext::new();
1691        ctx.set_line_cap(LineCap::Round);
1692        assert!(ctx.operations().contains("1 J\n"));
1693
1694        ctx.set_line_cap(LineCap::Butt);
1695        assert!(ctx.operations().contains("0 J\n"));
1696
1697        ctx.set_line_cap(LineCap::Square);
1698        assert!(ctx.operations().contains("2 J\n"));
1699    }
1700
1701    #[test]
1702    fn test_set_line_join() {
1703        let mut ctx = GraphicsContext::new();
1704        ctx.set_line_join(LineJoin::Round);
1705        assert!(ctx.operations().contains("1 j\n"));
1706
1707        ctx.set_line_join(LineJoin::Miter);
1708        assert!(ctx.operations().contains("0 j\n"));
1709
1710        ctx.set_line_join(LineJoin::Bevel);
1711        assert!(ctx.operations().contains("2 j\n"));
1712    }
1713
1714    #[test]
1715    fn test_save_restore_state() {
1716        let mut ctx = GraphicsContext::new();
1717        ctx.save_state();
1718        assert!(ctx.operations().contains("q\n"));
1719
1720        ctx.restore_state();
1721        assert!(ctx.operations().contains("Q\n"));
1722    }
1723
1724    #[test]
1725    fn test_translate() {
1726        let mut ctx = GraphicsContext::new();
1727        ctx.translate(50.0, 100.0);
1728        // v2.7.0 IR: every component of the `cm` matrix is emitted as `{:.2}`,
1729        // including identity-matrix slots (`1 0 0 1` → `1.00 0.00 0.00 1.00`).
1730        // See CHANGELOG → "cm matrix format" under 2.7.0.
1731        assert!(ctx
1732            .operations()
1733            .contains("1.00 0.00 0.00 1.00 50.00 100.00 cm\n"));
1734    }
1735
1736    #[test]
1737    fn test_scale() {
1738        let mut ctx = GraphicsContext::new();
1739        ctx.scale(2.0, 3.0);
1740        // v2.7.0 IR: see test_translate.
1741        assert!(ctx
1742            .operations()
1743            .contains("2.00 0.00 0.00 3.00 0.00 0.00 cm\n"));
1744    }
1745
1746    #[test]
1747    fn test_rotate() {
1748        let mut ctx = GraphicsContext::new();
1749        let angle = std::f64::consts::PI / 4.0; // 45 degrees
1750        ctx.rotate(angle);
1751
1752        let ops = ctx.operations();
1753        assert!(ops.contains(" cm\n"));
1754        // v2.7.0 IR: rotation matrix emitted at `{:.2}` (was `{:.6}`).
1755        // 0.707107 truncates to "0.71". See CHANGELOG.
1756        assert!(ops.contains("0.71"));
1757    }
1758
1759    #[test]
1760    fn test_transform() {
1761        let mut ctx = GraphicsContext::new();
1762        ctx.transform(1.0, 2.0, 3.0, 4.0, 5.0, 6.0);
1763        assert!(ctx
1764            .operations()
1765            .contains("1.00 2.00 3.00 4.00 5.00 6.00 cm\n"));
1766    }
1767
1768    #[test]
1769    fn test_draw_image() {
1770        let mut ctx = GraphicsContext::new();
1771        ctx.draw_image("Image1", 10.0, 20.0, 100.0, 150.0);
1772
1773        let ops = ctx.operations();
1774        assert!(ops.contains("q\n")); // Save state
1775                                      // v2.7.0 IR: cm matrix slots emitted at `{:.2}` consistently. See CHANGELOG.
1776        assert!(ops.contains("100.00 0.00 0.00 150.00 10.00 20.00 cm\n"));
1777        assert!(ops.contains("/Image1 Do\n")); // Draw image
1778        assert!(ops.contains("Q\n")); // Restore state
1779    }
1780
1781    #[test]
1782    fn test_gray_color_operations() {
1783        let mut ctx = GraphicsContext::new();
1784        ctx.set_stroke_color(Color::gray(0.5));
1785        ctx.set_fill_color(Color::gray(0.7));
1786        ctx.stroke();
1787        ctx.fill();
1788
1789        let ops = ctx.operations();
1790        assert!(ops.contains("0.500 G\n")); // Stroke gray
1791        assert!(ops.contains("0.700 g\n")); // Fill gray
1792    }
1793
1794    #[test]
1795    fn test_cmyk_color_operations() {
1796        let mut ctx = GraphicsContext::new();
1797        ctx.set_stroke_color(Color::cmyk(0.1, 0.2, 0.3, 0.4));
1798        ctx.set_fill_color(Color::cmyk(0.5, 0.6, 0.7, 0.8));
1799        ctx.stroke();
1800        ctx.fill();
1801
1802        let ops = ctx.operations();
1803        assert!(ops.contains("0.100 0.200 0.300 0.400 K\n")); // Stroke CMYK
1804        assert!(ops.contains("0.500 0.600 0.700 0.800 k\n")); // Fill CMYK
1805    }
1806
1807    #[test]
1808    fn test_method_chaining() {
1809        let mut ctx = GraphicsContext::new();
1810        ctx.move_to(0.0, 0.0)
1811            .line_to(10.0, 0.0)
1812            .line_to(10.0, 10.0)
1813            .line_to(0.0, 10.0)
1814            .close_path()
1815            .set_fill_color(Color::red())
1816            .fill();
1817
1818        let ops = ctx.operations();
1819        assert!(ops.contains("0.00 0.00 m\n"));
1820        assert!(ops.contains("10.00 0.00 l\n"));
1821        assert!(ops.contains("10.00 10.00 l\n"));
1822        assert!(ops.contains("0.00 10.00 l\n"));
1823        assert!(ops.contains("h\n"));
1824        assert!(ops.contains("f\n"));
1825    }
1826
1827    #[test]
1828    fn test_generate_operations() {
1829        let mut ctx = GraphicsContext::new();
1830        ctx.rect(0.0, 0.0, 10.0, 10.0);
1831
1832        let result = ctx.generate_operations();
1833        assert!(result.is_ok());
1834        let bytes = result.expect("Writing to string should never fail");
1835        let ops_string = String::from_utf8(bytes).expect("Writing to string should never fail");
1836        assert!(ops_string.contains("0.00 0.00 10.00 10.00 re"));
1837    }
1838
1839    #[test]
1840    fn test_clear_operations() {
1841        let mut ctx = GraphicsContext::new();
1842        ctx.rect(0.0, 0.0, 10.0, 10.0);
1843        assert!(!ctx.operations().is_empty());
1844
1845        ctx.clear();
1846        assert!(ctx.operations().is_empty());
1847    }
1848
1849    #[test]
1850    fn test_complex_path() {
1851        let mut ctx = GraphicsContext::new();
1852        ctx.save_state()
1853            .translate(100.0, 100.0)
1854            .rotate(std::f64::consts::PI / 6.0)
1855            .scale(2.0, 2.0)
1856            .set_line_width(2.0)
1857            .set_stroke_color(Color::blue())
1858            .move_to(0.0, 0.0)
1859            .line_to(50.0, 0.0)
1860            .curve_to(50.0, 25.0, 25.0, 50.0, 0.0, 50.0)
1861            .close_path()
1862            .stroke()
1863            .restore_state();
1864
1865        let ops = ctx.operations();
1866        assert!(ops.contains("q\n"));
1867        assert!(ops.contains("cm\n"));
1868        assert!(ops.contains("2.00 w\n"));
1869        assert!(ops.contains("0.000 0.000 1.000 RG\n"));
1870        assert!(ops.contains("S\n"));
1871        assert!(ops.contains("Q\n"));
1872    }
1873
1874    #[test]
1875    fn test_graphics_context_clone() {
1876        let mut ctx = GraphicsContext::new();
1877        ctx.set_fill_color(Color::red());
1878        ctx.set_stroke_color(Color::blue());
1879        ctx.set_line_width(3.0);
1880        ctx.set_opacity(0.5);
1881        ctx.rect(0.0, 0.0, 10.0, 10.0);
1882
1883        let ctx_clone = ctx.clone();
1884        assert_eq!(ctx_clone.fill_color(), Color::red());
1885        assert_eq!(ctx_clone.stroke_color(), Color::blue());
1886        assert_eq!(ctx_clone.line_width(), 3.0);
1887        assert_eq!(ctx_clone.fill_opacity(), 0.5);
1888        assert_eq!(ctx_clone.stroke_opacity(), 0.5);
1889        assert_eq!(ctx_clone.operations(), ctx.operations());
1890    }
1891
1892    #[test]
1893    fn test_set_opacity() {
1894        let mut ctx = GraphicsContext::new();
1895
1896        // Test setting opacity
1897        ctx.set_opacity(0.5);
1898        assert_eq!(ctx.fill_opacity(), 0.5);
1899        assert_eq!(ctx.stroke_opacity(), 0.5);
1900
1901        // Test clamping to valid range
1902        ctx.set_opacity(1.5);
1903        assert_eq!(ctx.fill_opacity(), 1.0);
1904        assert_eq!(ctx.stroke_opacity(), 1.0);
1905
1906        ctx.set_opacity(-0.5);
1907        assert_eq!(ctx.fill_opacity(), 0.0);
1908        assert_eq!(ctx.stroke_opacity(), 0.0);
1909    }
1910
1911    #[test]
1912    fn test_set_fill_opacity() {
1913        let mut ctx = GraphicsContext::new();
1914
1915        ctx.set_fill_opacity(0.3);
1916        assert_eq!(ctx.fill_opacity(), 0.3);
1917        assert_eq!(ctx.stroke_opacity(), 1.0); // Should not affect stroke
1918
1919        // Test clamping
1920        ctx.set_fill_opacity(2.0);
1921        assert_eq!(ctx.fill_opacity(), 1.0);
1922    }
1923
1924    #[test]
1925    fn test_set_stroke_opacity() {
1926        let mut ctx = GraphicsContext::new();
1927
1928        ctx.set_stroke_opacity(0.7);
1929        assert_eq!(ctx.stroke_opacity(), 0.7);
1930        assert_eq!(ctx.fill_opacity(), 1.0); // Should not affect fill
1931
1932        // Test clamping
1933        ctx.set_stroke_opacity(-1.0);
1934        assert_eq!(ctx.stroke_opacity(), 0.0);
1935    }
1936
1937    #[test]
1938    fn test_uses_transparency() {
1939        let mut ctx = GraphicsContext::new();
1940
1941        // Initially no transparency
1942        assert!(!ctx.uses_transparency());
1943
1944        // With fill transparency
1945        ctx.set_fill_opacity(0.5);
1946        assert!(ctx.uses_transparency());
1947
1948        // Reset and test stroke transparency
1949        ctx.set_fill_opacity(1.0);
1950        assert!(!ctx.uses_transparency());
1951        ctx.set_stroke_opacity(0.8);
1952        assert!(ctx.uses_transparency());
1953
1954        // Both transparent
1955        ctx.set_fill_opacity(0.5);
1956        assert!(ctx.uses_transparency());
1957    }
1958
1959    #[test]
1960    fn test_generate_graphics_state_dict() {
1961        let mut ctx = GraphicsContext::new();
1962
1963        // No transparency
1964        assert_eq!(ctx.generate_graphics_state_dict(), None);
1965
1966        // Fill opacity only
1967        ctx.set_fill_opacity(0.5);
1968        let dict = ctx
1969            .generate_graphics_state_dict()
1970            .expect("Writing to string should never fail");
1971        assert!(dict.contains("/Type /ExtGState"));
1972        assert!(dict.contains("/ca 0.500"));
1973        assert!(!dict.contains("/CA"));
1974
1975        // Stroke opacity only
1976        ctx.set_fill_opacity(1.0);
1977        ctx.set_stroke_opacity(0.75);
1978        let dict = ctx
1979            .generate_graphics_state_dict()
1980            .expect("Writing to string should never fail");
1981        assert!(dict.contains("/Type /ExtGState"));
1982        assert!(dict.contains("/CA 0.750"));
1983        assert!(!dict.contains("/ca"));
1984
1985        // Both opacities
1986        ctx.set_fill_opacity(0.25);
1987        let dict = ctx
1988            .generate_graphics_state_dict()
1989            .expect("Writing to string should never fail");
1990        assert!(dict.contains("/Type /ExtGState"));
1991        assert!(dict.contains("/ca 0.250"));
1992        assert!(dict.contains("/CA 0.750"));
1993    }
1994
1995    #[test]
1996    fn test_opacity_with_graphics_operations() {
1997        let mut ctx = GraphicsContext::new();
1998
1999        ctx.set_fill_color(Color::red())
2000            .set_opacity(0.5)
2001            .rect(10.0, 10.0, 100.0, 100.0)
2002            .fill();
2003
2004        assert_eq!(ctx.fill_opacity(), 0.5);
2005        assert_eq!(ctx.stroke_opacity(), 0.5);
2006
2007        let ops = ctx.operations();
2008        assert!(ops.contains("10.00 10.00 100.00 100.00 re"));
2009        assert!(ops.contains("1.000 0.000 0.000 rg")); // Red color
2010        assert!(ops.contains("f")); // Fill
2011    }
2012
2013    #[test]
2014    fn test_begin_end_text() {
2015        let mut ctx = GraphicsContext::new();
2016        ctx.begin_text();
2017        assert!(ctx.operations().contains("BT\n"));
2018
2019        ctx.end_text();
2020        assert!(ctx.operations().contains("ET\n"));
2021    }
2022
2023    #[test]
2024    fn test_set_font() {
2025        let mut ctx = GraphicsContext::new();
2026        ctx.set_font(Font::Helvetica, 12.0);
2027        assert!(ctx.operations().contains("/Helvetica 12 Tf\n"));
2028
2029        ctx.set_font(Font::TimesBold, 14.5);
2030        assert!(ctx.operations().contains("/Times-Bold 14.5 Tf\n"));
2031    }
2032
2033    #[test]
2034    fn test_set_text_position() {
2035        let mut ctx = GraphicsContext::new();
2036        ctx.set_text_position(100.0, 200.0);
2037        assert!(ctx.operations().contains("100.00 200.00 Td\n"));
2038    }
2039
2040    #[test]
2041    fn test_show_text() {
2042        let mut ctx = GraphicsContext::new();
2043        ctx.show_text("Hello World")
2044            .expect("Writing to string should never fail");
2045        assert!(ctx.operations().contains("(Hello World) Tj\n"));
2046    }
2047
2048    #[test]
2049    fn test_show_text_with_escaping() {
2050        let mut ctx = GraphicsContext::new();
2051        ctx.show_text("Test (parentheses)")
2052            .expect("Writing to string should never fail");
2053        assert!(ctx.operations().contains("(Test \\(parentheses\\)) Tj\n"));
2054
2055        ctx.clear();
2056        ctx.show_text("Back\\slash")
2057            .expect("Writing to string should never fail");
2058        assert!(ctx.operations().contains("(Back\\\\slash) Tj\n"));
2059
2060        ctx.clear();
2061        ctx.show_text("Line\nBreak")
2062            .expect("Writing to string should never fail");
2063        assert!(ctx.operations().contains("(Line\\nBreak) Tj\n"));
2064    }
2065
2066    #[test]
2067    fn test_text_operations_chaining() {
2068        let mut ctx = GraphicsContext::new();
2069        ctx.begin_text()
2070            .set_font(Font::Courier, 10.0)
2071            .set_text_position(50.0, 100.0)
2072            .show_text("Test")
2073            .unwrap()
2074            .end_text();
2075
2076        let ops = ctx.operations();
2077        assert!(ops.contains("BT\n"));
2078        assert!(ops.contains("/Courier 10 Tf\n"));
2079        assert!(ops.contains("50.00 100.00 Td\n"));
2080        assert!(ops.contains("(Test) Tj\n"));
2081        assert!(ops.contains("ET\n"));
2082    }
2083
2084    #[test]
2085    fn test_clip() {
2086        let mut ctx = GraphicsContext::new();
2087        ctx.clip();
2088        assert!(ctx.operations().contains("W\n"));
2089    }
2090
2091    #[test]
2092    fn test_clip_even_odd() {
2093        let mut ctx = GraphicsContext::new();
2094        ctx.clip_even_odd();
2095        assert!(ctx.operations().contains("W*\n"));
2096    }
2097
2098    #[test]
2099    fn test_clipping_with_path() {
2100        let mut ctx = GraphicsContext::new();
2101
2102        // Create a rectangular clipping path
2103        ctx.rect(10.0, 10.0, 100.0, 50.0).clip();
2104
2105        let ops = ctx.operations();
2106        assert!(ops.contains("10.00 10.00 100.00 50.00 re\n"));
2107        assert!(ops.contains("W\n"));
2108    }
2109
2110    #[test]
2111    fn test_clipping_even_odd_with_path() {
2112        let mut ctx = GraphicsContext::new();
2113
2114        // Create a complex path and clip with even-odd rule
2115        ctx.move_to(0.0, 0.0)
2116            .line_to(100.0, 0.0)
2117            .line_to(100.0, 100.0)
2118            .line_to(0.0, 100.0)
2119            .close_path()
2120            .clip_even_odd();
2121
2122        let ops = ctx.operations();
2123        assert!(ops.contains("0.00 0.00 m\n"));
2124        assert!(ops.contains("100.00 0.00 l\n"));
2125        assert!(ops.contains("100.00 100.00 l\n"));
2126        assert!(ops.contains("0.00 100.00 l\n"));
2127        assert!(ops.contains("h\n"));
2128        assert!(ops.contains("W*\n"));
2129    }
2130
2131    #[test]
2132    fn test_clipping_chaining() {
2133        let mut ctx = GraphicsContext::new();
2134
2135        // Test method chaining with clipping
2136        ctx.save_state()
2137            .rect(20.0, 20.0, 60.0, 60.0)
2138            .clip()
2139            .set_fill_color(Color::red())
2140            .rect(0.0, 0.0, 100.0, 100.0)
2141            .fill()
2142            .restore_state();
2143
2144        let ops = ctx.operations();
2145        assert!(ops.contains("q\n"));
2146        assert!(ops.contains("20.00 20.00 60.00 60.00 re\n"));
2147        assert!(ops.contains("W\n"));
2148        assert!(ops.contains("1.000 0.000 0.000 rg\n"));
2149        assert!(ops.contains("0.00 0.00 100.00 100.00 re\n"));
2150        assert!(ops.contains("f\n"));
2151        assert!(ops.contains("Q\n"));
2152    }
2153
2154    #[test]
2155    fn test_multiple_clipping_regions() {
2156        let mut ctx = GraphicsContext::new();
2157
2158        // Test nested clipping regions
2159        ctx.save_state()
2160            .rect(0.0, 0.0, 200.0, 200.0)
2161            .clip()
2162            .save_state()
2163            .circle(100.0, 100.0, 50.0)
2164            .clip_even_odd()
2165            .set_fill_color(Color::blue())
2166            .rect(50.0, 50.0, 100.0, 100.0)
2167            .fill()
2168            .restore_state()
2169            .restore_state();
2170
2171        let ops = ctx.operations();
2172        // Check for nested save/restore states
2173        let q_count = ops.matches("q\n").count();
2174        let q_restore_count = ops.matches("Q\n").count();
2175        assert_eq!(q_count, 2);
2176        assert_eq!(q_restore_count, 2);
2177
2178        // Check for both clipping operations
2179        assert!(ops.contains("W\n"));
2180        assert!(ops.contains("W*\n"));
2181    }
2182
2183    // ============= Additional Critical Method Tests =============
2184
2185    #[test]
2186    fn test_move_to_and_line_to() {
2187        let mut ctx = GraphicsContext::new();
2188        ctx.move_to(100.0, 200.0).line_to(300.0, 400.0).stroke();
2189
2190        let ops = ctx
2191            .generate_operations()
2192            .expect("Writing to string should never fail");
2193        let ops_str = String::from_utf8_lossy(&ops);
2194        assert!(ops_str.contains("100.00 200.00 m"));
2195        assert!(ops_str.contains("300.00 400.00 l"));
2196        assert!(ops_str.contains("S"));
2197    }
2198
2199    #[test]
2200    fn test_bezier_curve() {
2201        let mut ctx = GraphicsContext::new();
2202        ctx.move_to(0.0, 0.0)
2203            .curve_to(10.0, 20.0, 30.0, 40.0, 50.0, 60.0)
2204            .stroke();
2205
2206        let ops = ctx
2207            .generate_operations()
2208            .expect("Writing to string should never fail");
2209        let ops_str = String::from_utf8_lossy(&ops);
2210        assert!(ops_str.contains("0.00 0.00 m"));
2211        assert!(ops_str.contains("10.00 20.00 30.00 40.00 50.00 60.00 c"));
2212        assert!(ops_str.contains("S"));
2213    }
2214
2215    #[test]
2216    fn test_circle_path() {
2217        let mut ctx = GraphicsContext::new();
2218        ctx.circle(100.0, 100.0, 50.0).fill();
2219
2220        let ops = ctx
2221            .generate_operations()
2222            .expect("Writing to string should never fail");
2223        let ops_str = String::from_utf8_lossy(&ops);
2224        // Circle should use bezier curves (c operator)
2225        assert!(ops_str.contains(" c"));
2226        assert!(ops_str.contains("f"));
2227    }
2228
2229    #[test]
2230    fn test_path_closing() {
2231        let mut ctx = GraphicsContext::new();
2232        ctx.move_to(0.0, 0.0)
2233            .line_to(100.0, 0.0)
2234            .line_to(100.0, 100.0)
2235            .close_path()
2236            .stroke();
2237
2238        let ops = ctx
2239            .generate_operations()
2240            .expect("Writing to string should never fail");
2241        let ops_str = String::from_utf8_lossy(&ops);
2242        assert!(ops_str.contains("h")); // close path operator
2243        assert!(ops_str.contains("S"));
2244    }
2245
2246    #[test]
2247    fn test_fill_and_stroke() {
2248        let mut ctx = GraphicsContext::new();
2249        ctx.rect(10.0, 10.0, 50.0, 50.0).fill_stroke();
2250
2251        let ops = ctx
2252            .generate_operations()
2253            .expect("Writing to string should never fail");
2254        let ops_str = String::from_utf8_lossy(&ops);
2255        assert!(ops_str.contains("10.00 10.00 50.00 50.00 re"));
2256        assert!(ops_str.contains("B")); // fill and stroke operator
2257    }
2258
2259    #[test]
2260    fn test_color_settings() {
2261        let mut ctx = GraphicsContext::new();
2262        ctx.set_fill_color(Color::rgb(1.0, 0.0, 0.0))
2263            .set_stroke_color(Color::rgb(0.0, 1.0, 0.0))
2264            .rect(10.0, 10.0, 50.0, 50.0)
2265            .fill_stroke(); // This will write the colors
2266
2267        assert_eq!(ctx.fill_color(), Color::rgb(1.0, 0.0, 0.0));
2268        assert_eq!(ctx.stroke_color(), Color::rgb(0.0, 1.0, 0.0));
2269
2270        let ops = ctx
2271            .generate_operations()
2272            .expect("Writing to string should never fail");
2273        let ops_str = String::from_utf8_lossy(&ops);
2274        assert!(ops_str.contains("1.000 0.000 0.000 rg")); // red fill
2275        assert!(ops_str.contains("0.000 1.000 0.000 RG")); // green stroke
2276    }
2277
2278    #[test]
2279    fn test_line_styles() {
2280        let mut ctx = GraphicsContext::new();
2281        ctx.set_line_width(2.5)
2282            .set_line_cap(LineCap::Round)
2283            .set_line_join(LineJoin::Bevel);
2284
2285        assert_eq!(ctx.line_width(), 2.5);
2286
2287        let ops = ctx
2288            .generate_operations()
2289            .expect("Writing to string should never fail");
2290        let ops_str = String::from_utf8_lossy(&ops);
2291        assert!(ops_str.contains("2.50 w")); // line width
2292        assert!(ops_str.contains("1 J")); // round line cap
2293        assert!(ops_str.contains("2 j")); // bevel line join
2294    }
2295
2296    #[test]
2297    fn test_opacity_settings() {
2298        let mut ctx = GraphicsContext::new();
2299        ctx.set_opacity(0.5);
2300
2301        assert_eq!(ctx.fill_opacity(), 0.5);
2302        assert_eq!(ctx.stroke_opacity(), 0.5);
2303        assert!(ctx.uses_transparency());
2304
2305        ctx.set_fill_opacity(0.7).set_stroke_opacity(0.3);
2306
2307        assert_eq!(ctx.fill_opacity(), 0.7);
2308        assert_eq!(ctx.stroke_opacity(), 0.3);
2309    }
2310
2311    #[test]
2312    fn test_state_save_restore() {
2313        let mut ctx = GraphicsContext::new();
2314        ctx.save_state()
2315            .set_fill_color(Color::rgb(1.0, 0.0, 0.0))
2316            .restore_state();
2317
2318        let ops = ctx
2319            .generate_operations()
2320            .expect("Writing to string should never fail");
2321        let ops_str = String::from_utf8_lossy(&ops);
2322        assert!(ops_str.contains("q")); // save state
2323        assert!(ops_str.contains("Q")); // restore state
2324    }
2325
2326    #[test]
2327    fn test_transformations() {
2328        let mut ctx = GraphicsContext::new();
2329        ctx.translate(100.0, 200.0).scale(2.0, 3.0).rotate(45.0);
2330
2331        let ops = ctx
2332            .generate_operations()
2333            .expect("Writing to string should never fail");
2334        let ops_str = String::from_utf8_lossy(&ops);
2335        // v2.7.0 IR: cm matrix slots emitted at `{:.2}` consistently
2336        // (identity slots are no longer integer literals). See CHANGELOG.
2337        assert!(ops_str.contains("1.00 0.00 0.00 1.00 100.00 200.00 cm")); // translate
2338        assert!(ops_str.contains("2.00 0.00 0.00 3.00 0.00 0.00 cm")); // scale
2339        assert!(ops_str.contains("cm")); // rotate matrix
2340    }
2341
2342    #[test]
2343    fn test_custom_transform() {
2344        let mut ctx = GraphicsContext::new();
2345        ctx.transform(1.0, 0.5, 0.5, 1.0, 10.0, 20.0);
2346
2347        let ops = ctx
2348            .generate_operations()
2349            .expect("Writing to string should never fail");
2350        let ops_str = String::from_utf8_lossy(&ops);
2351        assert!(ops_str.contains("1.00 0.50 0.50 1.00 10.00 20.00 cm"));
2352    }
2353
2354    #[test]
2355    fn test_rectangle_path() {
2356        let mut ctx = GraphicsContext::new();
2357        ctx.rectangle(25.0, 25.0, 150.0, 100.0).stroke();
2358
2359        let ops = ctx
2360            .generate_operations()
2361            .expect("Writing to string should never fail");
2362        let ops_str = String::from_utf8_lossy(&ops);
2363        assert!(ops_str.contains("25.00 25.00 150.00 100.00 re"));
2364        assert!(ops_str.contains("S"));
2365    }
2366
2367    #[test]
2368    fn test_empty_operations() {
2369        let ctx = GraphicsContext::new();
2370        let ops = ctx
2371            .generate_operations()
2372            .expect("Writing to string should never fail");
2373        assert!(ops.is_empty());
2374    }
2375
2376    #[test]
2377    fn test_complex_path_operations() {
2378        let mut ctx = GraphicsContext::new();
2379        ctx.move_to(50.0, 50.0)
2380            .line_to(100.0, 50.0)
2381            .curve_to(125.0, 50.0, 150.0, 75.0, 150.0, 100.0)
2382            .line_to(150.0, 150.0)
2383            .close_path()
2384            .fill();
2385
2386        let ops = ctx
2387            .generate_operations()
2388            .expect("Writing to string should never fail");
2389        let ops_str = String::from_utf8_lossy(&ops);
2390        assert!(ops_str.contains("50.00 50.00 m"));
2391        assert!(ops_str.contains("100.00 50.00 l"));
2392        assert!(ops_str.contains("125.00 50.00 150.00 75.00 150.00 100.00 c"));
2393        assert!(ops_str.contains("150.00 150.00 l"));
2394        assert!(ops_str.contains("h"));
2395        assert!(ops_str.contains("f"));
2396    }
2397
2398    #[test]
2399    fn test_graphics_state_dict_generation() {
2400        let mut ctx = GraphicsContext::new();
2401
2402        // Without transparency, should return None
2403        assert!(ctx.generate_graphics_state_dict().is_none());
2404
2405        // With transparency, should generate dict
2406        ctx.set_opacity(0.5);
2407        let dict = ctx.generate_graphics_state_dict();
2408        assert!(dict.is_some());
2409        let dict_str = dict.expect("Writing to string should never fail");
2410        assert!(dict_str.contains("/ca 0.5"));
2411        assert!(dict_str.contains("/CA 0.5"));
2412    }
2413
2414    #[test]
2415    fn test_line_dash_pattern() {
2416        let mut ctx = GraphicsContext::new();
2417        let pattern = LineDashPattern {
2418            array: vec![3.0, 2.0],
2419            phase: 0.0,
2420        };
2421        ctx.set_line_dash_pattern(pattern);
2422
2423        let ops = ctx
2424            .generate_operations()
2425            .expect("Writing to string should never fail");
2426        let ops_str = String::from_utf8_lossy(&ops);
2427        assert!(ops_str.contains("[3.00 2.00] 0.00 d"));
2428    }
2429
2430    #[test]
2431    fn test_miter_limit_setting() {
2432        let mut ctx = GraphicsContext::new();
2433        ctx.set_miter_limit(4.0);
2434
2435        let ops = ctx
2436            .generate_operations()
2437            .expect("Writing to string should never fail");
2438        let ops_str = String::from_utf8_lossy(&ops);
2439        assert!(ops_str.contains("4.00 M"));
2440    }
2441
2442    #[test]
2443    fn test_line_cap_styles() {
2444        let mut ctx = GraphicsContext::new();
2445
2446        ctx.set_line_cap(LineCap::Butt);
2447        let ops = ctx
2448            .generate_operations()
2449            .expect("Writing to string should never fail");
2450        let ops_str = String::from_utf8_lossy(&ops);
2451        assert!(ops_str.contains("0 J"));
2452
2453        let mut ctx = GraphicsContext::new();
2454        ctx.set_line_cap(LineCap::Round);
2455        let ops = ctx
2456            .generate_operations()
2457            .expect("Writing to string should never fail");
2458        let ops_str = String::from_utf8_lossy(&ops);
2459        assert!(ops_str.contains("1 J"));
2460
2461        let mut ctx = GraphicsContext::new();
2462        ctx.set_line_cap(LineCap::Square);
2463        let ops = ctx
2464            .generate_operations()
2465            .expect("Writing to string should never fail");
2466        let ops_str = String::from_utf8_lossy(&ops);
2467        assert!(ops_str.contains("2 J"));
2468    }
2469
2470    #[test]
2471    fn test_transparency_groups() {
2472        let mut ctx = GraphicsContext::new();
2473
2474        // Test basic transparency group
2475        let group = TransparencyGroup::new()
2476            .with_isolated(true)
2477            .with_opacity(0.5);
2478
2479        ctx.begin_transparency_group(group);
2480        assert!(ctx.in_transparency_group());
2481
2482        // Draw something in the group
2483        ctx.rect(10.0, 10.0, 100.0, 100.0);
2484        ctx.fill();
2485
2486        ctx.end_transparency_group();
2487        assert!(!ctx.in_transparency_group());
2488
2489        // Check that operations contain transparency markers
2490        let ops = ctx.operations();
2491        assert!(ops.contains("% Begin Transparency Group"));
2492        assert!(ops.contains("% End Transparency Group"));
2493    }
2494
2495    #[test]
2496    fn test_nested_transparency_groups() {
2497        let mut ctx = GraphicsContext::new();
2498
2499        // First group
2500        let group1 = TransparencyGroup::isolated().with_opacity(0.8);
2501        ctx.begin_transparency_group(group1);
2502        assert!(ctx.in_transparency_group());
2503
2504        // Nested group
2505        let group2 = TransparencyGroup::knockout().with_blend_mode(BlendMode::Multiply);
2506        ctx.begin_transparency_group(group2);
2507
2508        // Draw in nested group
2509        ctx.circle(50.0, 50.0, 25.0);
2510        ctx.fill();
2511
2512        // End nested group
2513        ctx.end_transparency_group();
2514        assert!(ctx.in_transparency_group()); // Still in first group
2515
2516        // End first group
2517        ctx.end_transparency_group();
2518        assert!(!ctx.in_transparency_group());
2519    }
2520
2521    #[test]
2522    fn test_line_join_styles() {
2523        let mut ctx = GraphicsContext::new();
2524
2525        ctx.set_line_join(LineJoin::Miter);
2526        let ops = ctx
2527            .generate_operations()
2528            .expect("Writing to string should never fail");
2529        let ops_str = String::from_utf8_lossy(&ops);
2530        assert!(ops_str.contains("0 j"));
2531
2532        let mut ctx = GraphicsContext::new();
2533        ctx.set_line_join(LineJoin::Round);
2534        let ops = ctx
2535            .generate_operations()
2536            .expect("Writing to string should never fail");
2537        let ops_str = String::from_utf8_lossy(&ops);
2538        assert!(ops_str.contains("1 j"));
2539
2540        let mut ctx = GraphicsContext::new();
2541        ctx.set_line_join(LineJoin::Bevel);
2542        let ops = ctx
2543            .generate_operations()
2544            .expect("Writing to string should never fail");
2545        let ops_str = String::from_utf8_lossy(&ops);
2546        assert!(ops_str.contains("2 j"));
2547    }
2548
2549    #[test]
2550    fn test_rendering_intent() {
2551        let mut ctx = GraphicsContext::new();
2552
2553        ctx.set_rendering_intent(RenderingIntent::AbsoluteColorimetric);
2554        assert_eq!(
2555            ctx.rendering_intent(),
2556            RenderingIntent::AbsoluteColorimetric
2557        );
2558
2559        ctx.set_rendering_intent(RenderingIntent::Perceptual);
2560        assert_eq!(ctx.rendering_intent(), RenderingIntent::Perceptual);
2561
2562        ctx.set_rendering_intent(RenderingIntent::Saturation);
2563        assert_eq!(ctx.rendering_intent(), RenderingIntent::Saturation);
2564    }
2565
2566    #[test]
2567    fn test_flatness_tolerance() {
2568        let mut ctx = GraphicsContext::new();
2569
2570        ctx.set_flatness(0.5);
2571        assert_eq!(ctx.flatness(), 0.5);
2572
2573        let ops = ctx
2574            .generate_operations()
2575            .expect("Writing to string should never fail");
2576        let ops_str = String::from_utf8_lossy(&ops);
2577        assert!(ops_str.contains("0.50 i"));
2578    }
2579
2580    #[test]
2581    fn test_smoothness_tolerance() {
2582        let mut ctx = GraphicsContext::new();
2583
2584        let _ = ctx.set_smoothness(0.1);
2585        assert_eq!(ctx.smoothness(), 0.1);
2586    }
2587
2588    #[test]
2589    fn test_bezier_curves() {
2590        let mut ctx = GraphicsContext::new();
2591
2592        // Cubic Bezier
2593        ctx.move_to(10.0, 10.0);
2594        ctx.curve_to(20.0, 10.0, 30.0, 20.0, 30.0, 30.0);
2595
2596        let ops = ctx
2597            .generate_operations()
2598            .expect("Writing to string should never fail");
2599        let ops_str = String::from_utf8_lossy(&ops);
2600        assert!(ops_str.contains("10.00 10.00 m"));
2601        assert!(ops_str.contains("c")); // cubic curve
2602    }
2603
2604    #[test]
2605    fn test_clipping_path() {
2606        let mut ctx = GraphicsContext::new();
2607
2608        ctx.rectangle(10.0, 10.0, 100.0, 100.0);
2609        ctx.clip();
2610
2611        let ops = ctx
2612            .generate_operations()
2613            .expect("Writing to string should never fail");
2614        let ops_str = String::from_utf8_lossy(&ops);
2615        assert!(ops_str.contains("W"));
2616    }
2617
2618    #[test]
2619    fn test_even_odd_clipping() {
2620        let mut ctx = GraphicsContext::new();
2621
2622        ctx.rectangle(10.0, 10.0, 100.0, 100.0);
2623        ctx.clip_even_odd();
2624
2625        let ops = ctx
2626            .generate_operations()
2627            .expect("Writing to string should never fail");
2628        let ops_str = String::from_utf8_lossy(&ops);
2629        assert!(ops_str.contains("W*"));
2630    }
2631
2632    #[test]
2633    fn test_color_creation() {
2634        // Test color creation methods
2635        let gray = Color::gray(0.5);
2636        assert_eq!(gray, Color::Gray(0.5));
2637
2638        let rgb = Color::rgb(0.2, 0.4, 0.6);
2639        assert_eq!(rgb, Color::Rgb(0.2, 0.4, 0.6));
2640
2641        let cmyk = Color::cmyk(0.1, 0.2, 0.3, 0.4);
2642        assert_eq!(cmyk, Color::Cmyk(0.1, 0.2, 0.3, 0.4));
2643
2644        // Test predefined colors
2645        assert_eq!(Color::black(), Color::Gray(0.0));
2646        assert_eq!(Color::white(), Color::Gray(1.0));
2647        assert_eq!(Color::red(), Color::Rgb(1.0, 0.0, 0.0));
2648    }
2649
2650    #[test]
2651    fn test_extended_graphics_state() {
2652        let ctx = GraphicsContext::new();
2653
2654        // Test that we can create and use an extended graphics state
2655        let _extgstate = ExtGState::new();
2656
2657        // We should be able to create the state without errors
2658        assert!(ctx.generate_operations().is_ok());
2659    }
2660
2661    #[test]
2662    fn test_path_construction_methods() {
2663        let mut ctx = GraphicsContext::new();
2664
2665        // Test basic path construction methods that exist
2666        ctx.move_to(10.0, 10.0);
2667        ctx.line_to(20.0, 20.0);
2668        ctx.curve_to(30.0, 30.0, 40.0, 40.0, 50.0, 50.0);
2669        ctx.rect(60.0, 60.0, 30.0, 30.0);
2670        ctx.circle(100.0, 100.0, 25.0);
2671        ctx.close_path();
2672
2673        let ops = ctx
2674            .generate_operations()
2675            .expect("Writing to string should never fail");
2676        assert!(!ops.is_empty());
2677    }
2678
2679    #[test]
2680    fn test_graphics_context_clone_advanced() {
2681        let mut ctx = GraphicsContext::new();
2682        ctx.set_fill_color(Color::rgb(1.0, 0.0, 0.0));
2683        ctx.set_line_width(5.0);
2684
2685        let cloned = ctx.clone();
2686        assert_eq!(cloned.fill_color(), Color::rgb(1.0, 0.0, 0.0));
2687        assert_eq!(cloned.line_width(), 5.0);
2688    }
2689
2690    #[test]
2691    fn test_basic_drawing_operations() {
2692        let mut ctx = GraphicsContext::new();
2693
2694        // Test that we can at least create a basic drawing
2695        ctx.move_to(50.0, 50.0);
2696        ctx.line_to(100.0, 100.0);
2697        ctx.stroke();
2698
2699        let ops = ctx
2700            .generate_operations()
2701            .expect("Writing to string should never fail");
2702        let ops_str = String::from_utf8_lossy(&ops);
2703        assert!(ops_str.contains("m")); // move
2704        assert!(ops_str.contains("l")); // line
2705        assert!(ops_str.contains("S")); // stroke
2706    }
2707
2708    #[test]
2709    fn test_graphics_state_stack() {
2710        let mut ctx = GraphicsContext::new();
2711
2712        // Initial state
2713        ctx.set_fill_color(Color::black());
2714
2715        // Save and change
2716        ctx.save_state();
2717        ctx.set_fill_color(Color::red());
2718        assert_eq!(ctx.fill_color(), Color::red());
2719
2720        // Save again and change
2721        ctx.save_state();
2722        ctx.set_fill_color(Color::blue());
2723        assert_eq!(ctx.fill_color(), Color::blue());
2724
2725        // Restore once
2726        ctx.restore_state();
2727        assert_eq!(ctx.fill_color(), Color::red());
2728
2729        // Restore again
2730        ctx.restore_state();
2731        assert_eq!(ctx.fill_color(), Color::black());
2732    }
2733
2734    #[test]
2735    fn test_word_spacing() {
2736        let mut ctx = GraphicsContext::new();
2737        ctx.set_word_spacing(2.5);
2738
2739        let ops = ctx.generate_operations().unwrap();
2740        let ops_str = String::from_utf8_lossy(&ops);
2741        assert!(ops_str.contains("2.50 Tw"));
2742    }
2743
2744    #[test]
2745    fn test_character_spacing() {
2746        let mut ctx = GraphicsContext::new();
2747        ctx.set_character_spacing(1.0);
2748
2749        let ops = ctx.generate_operations().unwrap();
2750        let ops_str = String::from_utf8_lossy(&ops);
2751        assert!(ops_str.contains("1.00 Tc"));
2752    }
2753
2754    #[test]
2755    fn test_justified_text() {
2756        let mut ctx = GraphicsContext::new();
2757        ctx.begin_text();
2758        ctx.set_text_position(100.0, 200.0);
2759        ctx.show_justified_text("Hello world from PDF", 200.0)
2760            .unwrap();
2761        ctx.end_text();
2762
2763        let ops = ctx.generate_operations().unwrap();
2764        let ops_str = String::from_utf8_lossy(&ops);
2765
2766        // Should contain text operations
2767        assert!(ops_str.contains("BT")); // Begin text
2768        assert!(ops_str.contains("ET")); // End text
2769        assert!(ops_str.contains("100.00 200.00 Td")); // Text position
2770        assert!(ops_str.contains("(Hello world from PDF) Tj")); // Show text
2771
2772        // Should contain word spacing operations
2773        assert!(ops_str.contains("Tw")); // Word spacing
2774    }
2775
2776    #[test]
2777    fn test_justified_text_single_word() {
2778        let mut ctx = GraphicsContext::new();
2779        ctx.begin_text();
2780        ctx.show_justified_text("Hello", 200.0).unwrap();
2781        ctx.end_text();
2782
2783        let ops = ctx.generate_operations().unwrap();
2784        let ops_str = String::from_utf8_lossy(&ops);
2785
2786        // Single word should just use normal text display
2787        assert!(ops_str.contains("(Hello) Tj"));
2788        // Should not contain word spacing since there's only one word
2789        assert_eq!(ops_str.matches("Tw").count(), 0);
2790    }
2791
2792    #[test]
2793    fn test_text_width_estimation() {
2794        let ctx = GraphicsContext::new();
2795        let width = ctx.estimate_text_width_simple("Hello");
2796
2797        // Should return reasonable estimation based on font size and character count
2798        assert!(width > 0.0);
2799        assert_eq!(width, 5.0 * 12.0 * 0.6); // 5 chars * 12pt font * 0.6 factor
2800    }
2801
2802    #[test]
2803    fn test_set_alpha_methods() {
2804        let mut ctx = GraphicsContext::new();
2805
2806        // Test that set_alpha methods don't panic and return correctly
2807        assert!(ctx.set_alpha(0.5).is_ok());
2808        assert!(ctx.set_alpha_fill(0.3).is_ok());
2809        assert!(ctx.set_alpha_stroke(0.7).is_ok());
2810
2811        // Test edge cases - should handle clamping in ExtGState
2812        assert!(ctx.set_alpha(1.5).is_ok()); // Should not panic
2813        assert!(ctx.set_alpha(-0.2).is_ok()); // Should not panic
2814        assert!(ctx.set_alpha_fill(2.0).is_ok()); // Should not panic
2815        assert!(ctx.set_alpha_stroke(-1.0).is_ok()); // Should not panic
2816
2817        // Test that methods return self for chaining
2818        let result = ctx
2819            .set_alpha(0.5)
2820            .and_then(|c| c.set_alpha_fill(0.3))
2821            .and_then(|c| c.set_alpha_stroke(0.7));
2822        assert!(result.is_ok());
2823    }
2824
2825    #[test]
2826    fn test_alpha_methods_generate_extgstate() {
2827        let mut ctx = GraphicsContext::new();
2828
2829        // Set some transparency
2830        ctx.set_alpha(0.5).unwrap();
2831
2832        // Draw something to trigger ExtGState generation
2833        ctx.rect(10.0, 10.0, 50.0, 50.0).fill();
2834
2835        let ops = ctx.generate_operations().unwrap();
2836        let ops_str = String::from_utf8_lossy(&ops);
2837
2838        // Should contain ExtGState reference
2839        assert!(ops_str.contains("/GS")); // ExtGState name
2840        assert!(ops_str.contains(" gs\n")); // ExtGState operator
2841
2842        // Test separate alpha settings
2843        ctx.clear();
2844        ctx.set_alpha_fill(0.3).unwrap();
2845        ctx.set_alpha_stroke(0.8).unwrap();
2846        ctx.rect(20.0, 20.0, 60.0, 60.0).fill_stroke();
2847
2848        let ops2 = ctx.generate_operations().unwrap();
2849        let ops_str2 = String::from_utf8_lossy(&ops2);
2850
2851        // Should contain multiple ExtGState references
2852        assert!(ops_str2.contains("/GS")); // ExtGState names
2853        assert!(ops_str2.contains(" gs\n")); // ExtGState operators
2854    }
2855
2856    #[test]
2857    fn test_add_command() {
2858        let mut ctx = GraphicsContext::new();
2859
2860        // Test normal command
2861        ctx.add_command("1 0 0 1 100 200 cm");
2862        let ops = ctx.operations();
2863        assert!(ops.contains("1 0 0 1 100 200 cm\n"));
2864
2865        // Test that newline is always added
2866        ctx.clear();
2867        ctx.add_command("q");
2868        assert_eq!(ctx.operations(), "q\n");
2869
2870        // Test empty string
2871        ctx.clear();
2872        ctx.add_command("");
2873        assert_eq!(ctx.operations(), "\n");
2874
2875        // Test command with existing newline
2876        ctx.clear();
2877        ctx.add_command("Q\n");
2878        assert_eq!(ctx.operations(), "Q\n\n"); // Double newline
2879
2880        // Test multiple commands
2881        ctx.clear();
2882        ctx.add_command("q");
2883        ctx.add_command("1 0 0 1 50 50 cm");
2884        ctx.add_command("Q");
2885        assert_eq!(ctx.operations(), "q\n1 0 0 1 50 50 cm\nQ\n");
2886    }
2887
2888    #[test]
2889    fn test_get_operations() {
2890        let mut ctx = GraphicsContext::new();
2891        ctx.rect(10.0, 10.0, 50.0, 50.0);
2892        let ops1 = ctx.operations();
2893        let ops2 = ctx.get_operations();
2894        assert_eq!(ops1, ops2);
2895    }
2896
2897    #[test]
2898    fn test_set_line_solid() {
2899        let mut ctx = GraphicsContext::new();
2900        ctx.set_line_dash_pattern(LineDashPattern::new(vec![5.0, 3.0], 0.0));
2901        ctx.set_line_solid();
2902        let ops = ctx.operations();
2903        assert!(ops.contains("[] 0 d\n"));
2904    }
2905
2906    #[test]
2907    fn test_set_custom_font() {
2908        let mut ctx = GraphicsContext::new();
2909        ctx.set_custom_font("CustomFont", 14.0);
2910        assert_eq!(ctx.current_font_name.as_deref(), Some("CustomFont"));
2911        assert_eq!(ctx.current_font_size, 14.0);
2912        assert!(ctx.is_custom_font);
2913    }
2914
2915    #[test]
2916    fn test_show_text_standard_font_uses_literal_string() {
2917        let mut ctx = GraphicsContext::new();
2918        ctx.set_font(Font::Helvetica, 12.0);
2919        assert!(!ctx.is_custom_font);
2920
2921        ctx.begin_text();
2922        ctx.set_text_position(10.0, 20.0);
2923        ctx.show_text("Hello World").unwrap();
2924        ctx.end_text();
2925
2926        let ops = ctx.operations();
2927        assert!(ops.contains("(Hello World) Tj"));
2928        assert!(!ops.contains("<"));
2929    }
2930
2931    #[test]
2932    fn test_show_text_custom_font_uses_hex_encoding() {
2933        let mut ctx = GraphicsContext::new();
2934        ctx.set_font(Font::Custom("NotoSansCJK".to_string()), 12.0);
2935        assert!(ctx.is_custom_font);
2936
2937        ctx.begin_text();
2938        ctx.set_text_position(10.0, 20.0);
2939        // CJK characters: 你好 (U+4F60 U+597D)
2940        ctx.show_text("你好").unwrap();
2941        ctx.end_text();
2942
2943        let ops = ctx.operations();
2944        // Must be hex-encoded, not literal
2945        assert!(
2946            ops.contains("<4F60597D> Tj"),
2947            "Expected hex encoding for CJK text, got: {}",
2948            ops
2949        );
2950        assert!(!ops.contains("(你好)"));
2951    }
2952
2953    #[test]
2954    fn test_show_text_custom_font_ascii_still_hex() {
2955        let mut ctx = GraphicsContext::new();
2956        ctx.set_font(Font::Custom("MyFont".to_string()), 10.0);
2957
2958        ctx.begin_text();
2959        ctx.set_text_position(0.0, 0.0);
2960        // Even ASCII text should be hex-encoded when using custom font
2961        ctx.show_text("AB").unwrap();
2962        ctx.end_text();
2963
2964        let ops = ctx.operations();
2965        // A=0x0041, B=0x0042
2966        assert!(
2967            ops.contains("<00410042> Tj"),
2968            "Expected hex encoding for ASCII in custom font, got: {}",
2969            ops
2970        );
2971    }
2972
2973    #[test]
2974    fn test_show_text_tracks_used_characters() {
2975        let mut ctx = GraphicsContext::new();
2976        ctx.set_font(Font::Custom("CJKFont".to_string()), 12.0);
2977
2978        ctx.begin_text();
2979        ctx.show_text("你好A").unwrap();
2980        ctx.end_text();
2981
2982        let chars = ctx
2983            .get_used_characters()
2984            .expect("show_text with a custom font must record characters");
2985        assert!(chars.contains(&'你'));
2986        assert!(chars.contains(&'好'));
2987        assert!(chars.contains(&'A'));
2988    }
2989
2990    #[test]
2991    fn test_is_custom_font_toggles_correctly() {
2992        let mut ctx = GraphicsContext::new();
2993        assert!(!ctx.is_custom_font);
2994
2995        ctx.set_font(Font::Custom("CJK".to_string()), 12.0);
2996        assert!(ctx.is_custom_font);
2997
2998        ctx.set_font(Font::Helvetica, 12.0);
2999        assert!(!ctx.is_custom_font);
3000
3001        ctx.set_custom_font("AnotherCJK", 14.0);
3002        assert!(ctx.is_custom_font);
3003
3004        ctx.set_font(Font::CourierBold, 10.0);
3005        assert!(!ctx.is_custom_font);
3006    }
3007
3008    #[test]
3009    fn test_set_glyph_mapping() {
3010        let mut ctx = GraphicsContext::new();
3011
3012        // Test initial state
3013        assert!(ctx.glyph_mapping.is_none());
3014
3015        // Test normal mapping
3016        let mut mapping = HashMap::new();
3017        mapping.insert(65u32, 1u16); // 'A' -> glyph 1
3018        mapping.insert(66u32, 2u16); // 'B' -> glyph 2
3019        ctx.set_glyph_mapping(mapping.clone());
3020        assert!(ctx.glyph_mapping.is_some());
3021        assert_eq!(ctx.glyph_mapping.as_ref().unwrap().len(), 2);
3022        assert_eq!(ctx.glyph_mapping.as_ref().unwrap().get(&65), Some(&1));
3023        assert_eq!(ctx.glyph_mapping.as_ref().unwrap().get(&66), Some(&2));
3024
3025        // Test empty mapping
3026        ctx.set_glyph_mapping(HashMap::new());
3027        assert!(ctx.glyph_mapping.is_some());
3028        assert_eq!(ctx.glyph_mapping.as_ref().unwrap().len(), 0);
3029
3030        // Test overwrite existing mapping
3031        let mut new_mapping = HashMap::new();
3032        new_mapping.insert(67u32, 3u16); // 'C' -> glyph 3
3033        ctx.set_glyph_mapping(new_mapping);
3034        assert_eq!(ctx.glyph_mapping.as_ref().unwrap().len(), 1);
3035        assert_eq!(ctx.glyph_mapping.as_ref().unwrap().get(&67), Some(&3));
3036        assert_eq!(ctx.glyph_mapping.as_ref().unwrap().get(&65), None); // Old mapping gone
3037    }
3038
3039    #[test]
3040    fn test_draw_text_basic() {
3041        let mut ctx = GraphicsContext::new();
3042        ctx.set_font(Font::Helvetica, 12.0);
3043
3044        let result = ctx.draw_text("Hello", 100.0, 200.0);
3045        assert!(result.is_ok());
3046
3047        let ops = ctx.operations();
3048        // Verify text block
3049        assert!(ops.contains("BT\n"));
3050        assert!(ops.contains("ET\n"));
3051
3052        // Verify font is set
3053        assert!(ops.contains("/Helvetica"));
3054        assert!(ops.contains("12"));
3055        assert!(ops.contains("Tf\n"));
3056
3057        // Verify positioning
3058        assert!(ops.contains("100"));
3059        assert!(ops.contains("200"));
3060        assert!(ops.contains("Td\n"));
3061
3062        // Verify text content
3063        assert!(ops.contains("(Hello)") || ops.contains("<48656c6c6f>")); // Text or hex
3064    }
3065
3066    #[test]
3067    fn test_draw_text_with_special_characters() {
3068        let mut ctx = GraphicsContext::new();
3069        ctx.set_font(Font::Helvetica, 12.0);
3070
3071        // Test with parentheses (must be escaped in PDF)
3072        let result = ctx.draw_text("Test (with) parens", 50.0, 100.0);
3073        assert!(result.is_ok());
3074
3075        let ops = ctx.operations();
3076        // Should escape parentheses
3077        assert!(ops.contains("\\(") || ops.contains("\\)") || ops.contains("<"));
3078        // Either escaped or hex
3079    }
3080
3081    #[test]
3082    fn test_draw_text_unicode_detection() {
3083        let mut ctx = GraphicsContext::new();
3084        ctx.set_font(Font::Helvetica, 12.0);
3085
3086        // ASCII text should use simple encoding
3087        ctx.draw_text("ASCII", 0.0, 0.0).unwrap();
3088        let _ops_ascii = ctx.operations();
3089
3090        ctx.clear();
3091
3092        // Unicode text should trigger different encoding
3093        ctx.set_font(Font::Helvetica, 12.0);
3094        ctx.draw_text("中文", 0.0, 0.0).unwrap();
3095        let ops_unicode = ctx.operations();
3096
3097        // Unicode should produce hex encoding
3098        assert!(ops_unicode.contains("<") && ops_unicode.contains(">"));
3099    }
3100
3101    #[test]
3102    #[allow(deprecated)]
3103    fn test_draw_text_hex_encoding() {
3104        let mut ctx = GraphicsContext::new();
3105        ctx.set_font(Font::Helvetica, 12.0);
3106        let result = ctx.draw_text_hex("Test", 50.0, 100.0);
3107        assert!(result.is_ok());
3108        let ops = ctx.operations();
3109        assert!(ops.contains("<"));
3110        assert!(ops.contains(">"));
3111    }
3112
3113    #[test]
3114    #[allow(deprecated)]
3115    fn test_draw_text_cid() {
3116        let mut ctx = GraphicsContext::new();
3117        ctx.set_custom_font("CustomCIDFont", 12.0);
3118        let result = ctx.draw_text_cid("Test", 50.0, 100.0);
3119        assert!(result.is_ok());
3120        let ops = ctx.operations();
3121        assert!(ops.contains("BT\n"));
3122        assert!(ops.contains("ET\n"));
3123    }
3124
3125    #[test]
3126    #[allow(deprecated)]
3127    fn test_draw_text_unicode() {
3128        let mut ctx = GraphicsContext::new();
3129        ctx.set_custom_font("UnicodeFont", 12.0);
3130        let result = ctx.draw_text_unicode("Test \u{4E2D}\u{6587}", 50.0, 100.0);
3131        assert!(result.is_ok());
3132        let ops = ctx.operations();
3133        assert!(ops.contains("BT\n"));
3134        assert!(ops.contains("ET\n"));
3135    }
3136
3137    #[test]
3138    fn test_begin_end_transparency_group() {
3139        let mut ctx = GraphicsContext::new();
3140
3141        // Initial state - no transparency group
3142        assert!(!ctx.in_transparency_group());
3143        assert!(ctx.current_transparency_group().is_none());
3144
3145        // Begin transparency group
3146        let group = TransparencyGroup::new();
3147        ctx.begin_transparency_group(group);
3148        assert!(ctx.in_transparency_group());
3149        assert!(ctx.current_transparency_group().is_some());
3150
3151        // Verify operations contain transparency marker
3152        let ops = ctx.operations();
3153        assert!(ops.contains("% Begin Transparency Group"));
3154
3155        // End transparency group
3156        ctx.end_transparency_group();
3157        assert!(!ctx.in_transparency_group());
3158        assert!(ctx.current_transparency_group().is_none());
3159
3160        // Verify end marker
3161        let ops_after = ctx.operations();
3162        assert!(ops_after.contains("% End Transparency Group"));
3163    }
3164
3165    #[test]
3166    fn test_transparency_group_nesting() {
3167        let mut ctx = GraphicsContext::new();
3168
3169        // Nest 3 levels
3170        let group1 = TransparencyGroup::new();
3171        let group2 = TransparencyGroup::new();
3172        let group3 = TransparencyGroup::new();
3173
3174        ctx.begin_transparency_group(group1);
3175        assert_eq!(ctx.transparency_stack.len(), 1);
3176
3177        ctx.begin_transparency_group(group2);
3178        assert_eq!(ctx.transparency_stack.len(), 2);
3179
3180        ctx.begin_transparency_group(group3);
3181        assert_eq!(ctx.transparency_stack.len(), 3);
3182
3183        // End all
3184        ctx.end_transparency_group();
3185        assert_eq!(ctx.transparency_stack.len(), 2);
3186
3187        ctx.end_transparency_group();
3188        assert_eq!(ctx.transparency_stack.len(), 1);
3189
3190        ctx.end_transparency_group();
3191        assert_eq!(ctx.transparency_stack.len(), 0);
3192        assert!(!ctx.in_transparency_group());
3193    }
3194
3195    #[test]
3196    fn test_transparency_group_without_begin() {
3197        let mut ctx = GraphicsContext::new();
3198
3199        // Try to end without begin - should not panic, just be no-op
3200        assert!(!ctx.in_transparency_group());
3201        ctx.end_transparency_group();
3202        assert!(!ctx.in_transparency_group());
3203    }
3204
3205    #[test]
3206    fn test_extgstate_manager_access() {
3207        let ctx = GraphicsContext::new();
3208        let manager = ctx.extgstate_manager();
3209        assert_eq!(manager.count(), 0);
3210    }
3211
3212    #[test]
3213    fn test_extgstate_manager_mut_access() {
3214        let mut ctx = GraphicsContext::new();
3215        let manager = ctx.extgstate_manager_mut();
3216        assert_eq!(manager.count(), 0);
3217    }
3218
3219    #[test]
3220    fn test_has_extgstates() {
3221        let mut ctx = GraphicsContext::new();
3222
3223        // Initially no extgstates
3224        assert!(!ctx.has_extgstates());
3225        assert_eq!(ctx.extgstate_manager().count(), 0);
3226
3227        // Adding transparency creates extgstate
3228        ctx.set_alpha(0.5).unwrap();
3229        ctx.rect(10.0, 10.0, 50.0, 50.0).fill();
3230        let result = ctx.generate_operations().unwrap();
3231
3232        assert!(ctx.has_extgstates());
3233        assert!(ctx.extgstate_manager().count() > 0);
3234
3235        // Verify extgstate is in PDF output
3236        let output = String::from_utf8_lossy(&result);
3237        assert!(output.contains("/GS")); // ExtGState reference
3238        assert!(output.contains(" gs\n")); // ExtGState operator
3239    }
3240
3241    #[test]
3242    fn test_generate_extgstate_resources() {
3243        let mut ctx = GraphicsContext::new();
3244        ctx.set_alpha(0.5).unwrap();
3245        ctx.rect(10.0, 10.0, 50.0, 50.0).fill();
3246        ctx.generate_operations().unwrap();
3247
3248        let resources = ctx.generate_extgstate_resources();
3249        assert!(resources.is_ok());
3250    }
3251
3252    #[test]
3253    fn test_apply_extgstate() {
3254        let mut ctx = GraphicsContext::new();
3255
3256        // Create ExtGState with specific values
3257        let mut state = ExtGState::new();
3258        state.alpha_fill = Some(0.5);
3259        state.alpha_stroke = Some(0.8);
3260        state.blend_mode = Some(BlendMode::Multiply);
3261
3262        let result = ctx.apply_extgstate(state);
3263        assert!(result.is_ok());
3264
3265        // Verify ExtGState was registered
3266        assert!(ctx.has_extgstates());
3267        assert_eq!(ctx.extgstate_manager().count(), 1);
3268
3269        // Apply different ExtGState
3270        let mut state2 = ExtGState::new();
3271        state2.alpha_fill = Some(0.3);
3272        ctx.apply_extgstate(state2).unwrap();
3273
3274        // Should have 2 different extgstates
3275        assert_eq!(ctx.extgstate_manager().count(), 2);
3276    }
3277
3278    #[test]
3279    fn test_with_extgstate() {
3280        let mut ctx = GraphicsContext::new();
3281        let result = ctx.with_extgstate(|mut state| {
3282            state.alpha_fill = Some(0.5);
3283            state.alpha_stroke = Some(0.8);
3284            state
3285        });
3286        assert!(result.is_ok());
3287    }
3288
3289    #[test]
3290    fn test_set_blend_mode() {
3291        let mut ctx = GraphicsContext::new();
3292
3293        // Test different blend modes
3294        let result = ctx.set_blend_mode(BlendMode::Multiply);
3295        assert!(result.is_ok());
3296        assert!(ctx.has_extgstates());
3297
3298        // Test that different blend modes create different extgstates
3299        ctx.clear();
3300        ctx.set_blend_mode(BlendMode::Screen).unwrap();
3301        ctx.rect(0.0, 0.0, 10.0, 10.0).fill();
3302        let ops = ctx.generate_operations().unwrap();
3303        let output = String::from_utf8_lossy(&ops);
3304
3305        // Should contain extgstate reference
3306        assert!(output.contains("/GS"));
3307        assert!(output.contains(" gs\n"));
3308    }
3309
3310    #[test]
3311    fn test_render_table() {
3312        let mut ctx = GraphicsContext::new();
3313        let table = Table::with_equal_columns(2, 200.0);
3314        let result = ctx.render_table(&table);
3315        assert!(result.is_ok());
3316    }
3317
3318    #[test]
3319    fn test_render_list() {
3320        let mut ctx = GraphicsContext::new();
3321        use crate::text::{OrderedList, OrderedListStyle};
3322        let ordered = OrderedList::new(OrderedListStyle::Decimal);
3323        let list = ListElement::Ordered(ordered);
3324        let result = ctx.render_list(&list);
3325        assert!(result.is_ok());
3326    }
3327
3328    #[test]
3329    fn test_render_column_layout() {
3330        let mut ctx = GraphicsContext::new();
3331        use crate::text::ColumnContent;
3332        let layout = ColumnLayout::new(2, 100.0, 200.0);
3333        let content = ColumnContent::new("Test content");
3334        let result = ctx.render_column_layout(&layout, &content, 50.0, 50.0, 400.0);
3335        assert!(result.is_ok());
3336    }
3337
3338    #[test]
3339    fn test_clip_ellipse() {
3340        let mut ctx = GraphicsContext::new();
3341
3342        // No clipping initially
3343        assert!(!ctx.has_clipping());
3344        assert!(ctx.clipping_path().is_none());
3345
3346        // Apply ellipse clipping
3347        let result = ctx.clip_ellipse(100.0, 100.0, 50.0, 30.0);
3348        assert!(result.is_ok());
3349        assert!(ctx.has_clipping());
3350        assert!(ctx.clipping_path().is_some());
3351
3352        // Verify clipping operations in PDF
3353        let ops = ctx.operations();
3354        assert!(ops.contains("W\n") || ops.contains("W*\n")); // Clipping operator
3355
3356        // Clear clipping
3357        ctx.clear_clipping();
3358        assert!(!ctx.has_clipping());
3359    }
3360
3361    #[test]
3362    fn test_clipping_path_access() {
3363        let mut ctx = GraphicsContext::new();
3364
3365        // No clipping initially
3366        assert!(ctx.clipping_path().is_none());
3367
3368        // Apply rect clipping
3369        ctx.clip_rect(10.0, 10.0, 50.0, 50.0).unwrap();
3370        assert!(ctx.clipping_path().is_some());
3371
3372        // Apply different clipping - should replace
3373        ctx.clip_circle(100.0, 100.0, 25.0).unwrap();
3374        assert!(ctx.clipping_path().is_some());
3375
3376        // Save/restore should preserve clipping
3377        ctx.save_state();
3378        ctx.clear_clipping();
3379        assert!(!ctx.has_clipping());
3380
3381        ctx.restore_state();
3382        // After restore, clipping should be back
3383        assert!(ctx.has_clipping());
3384    }
3385
3386    // ====== QUALITY TESTS: EDGE CASES ======
3387
3388    #[test]
3389    fn test_edge_case_move_to_negative() {
3390        let mut ctx = GraphicsContext::new();
3391        ctx.move_to(-100.5, -200.25);
3392        assert!(ctx.operations().contains("-100.50 -200.25 m\n"));
3393    }
3394
3395    #[test]
3396    fn test_edge_case_opacity_out_of_range() {
3397        let mut ctx = GraphicsContext::new();
3398
3399        // Above 1.0 - should clamp
3400        let _ = ctx.set_opacity(2.5);
3401        assert_eq!(ctx.fill_opacity(), 1.0);
3402
3403        // Below 0.0 - should clamp
3404        let _ = ctx.set_opacity(-0.5);
3405        assert_eq!(ctx.fill_opacity(), 0.0);
3406    }
3407
3408    #[test]
3409    fn test_edge_case_line_width_extremes() {
3410        let mut ctx = GraphicsContext::new();
3411
3412        ctx.set_line_width(0.0);
3413        assert_eq!(ctx.line_width(), 0.0);
3414
3415        ctx.set_line_width(10000.0);
3416        assert_eq!(ctx.line_width(), 10000.0);
3417    }
3418
3419    // ====== QUALITY TESTS: FEATURE INTERACTIONS ======
3420
3421    #[test]
3422    fn test_interaction_transparency_plus_clipping() {
3423        let mut ctx = GraphicsContext::new();
3424
3425        ctx.set_alpha(0.5).unwrap();
3426        ctx.clip_rect(10.0, 10.0, 100.0, 100.0).unwrap();
3427        ctx.rect(20.0, 20.0, 80.0, 80.0).fill();
3428
3429        let ops = ctx.generate_operations().unwrap();
3430        let output = String::from_utf8_lossy(&ops);
3431
3432        // Both features should be in PDF
3433        assert!(output.contains("W\n") || output.contains("W*\n"));
3434        assert!(output.contains("/GS"));
3435    }
3436
3437    #[test]
3438    fn test_interaction_extgstate_plus_text() {
3439        let mut ctx = GraphicsContext::new();
3440
3441        let mut state = ExtGState::new();
3442        state.alpha_fill = Some(0.7);
3443        ctx.apply_extgstate(state).unwrap();
3444
3445        ctx.set_font(Font::Helvetica, 14.0);
3446        ctx.draw_text("Test", 100.0, 200.0).unwrap();
3447
3448        let ops = ctx.generate_operations().unwrap();
3449        let output = String::from_utf8_lossy(&ops);
3450
3451        assert!(output.contains("/GS"));
3452        assert!(output.contains("BT\n"));
3453    }
3454
3455    #[test]
3456    fn test_interaction_chained_transformations() {
3457        let mut ctx = GraphicsContext::new();
3458
3459        ctx.translate(50.0, 100.0);
3460        ctx.rotate(45.0);
3461        ctx.scale(2.0, 2.0);
3462
3463        let ops = ctx.operations();
3464        assert_eq!(ops.matches("cm\n").count(), 3);
3465    }
3466
3467    // ====== QUALITY TESTS: END-TO-END ======
3468
3469    #[test]
3470    fn test_e2e_complete_page_with_header() {
3471        use crate::{Document, Page};
3472
3473        let mut doc = Document::new();
3474        let mut page = Page::a4();
3475        let ctx = page.graphics();
3476
3477        // Header
3478        ctx.save_state();
3479        let _ = ctx.set_fill_opacity(0.3);
3480        ctx.set_fill_color(Color::rgb(200.0, 200.0, 255.0));
3481        ctx.rect(0.0, 750.0, 595.0, 42.0).fill();
3482        ctx.restore_state();
3483
3484        // Content
3485        ctx.save_state();
3486        ctx.clip_rect(50.0, 50.0, 495.0, 692.0).unwrap();
3487        ctx.rect(60.0, 60.0, 100.0, 100.0).fill();
3488        ctx.restore_state();
3489
3490        let ops = ctx.generate_operations().unwrap();
3491        let output = String::from_utf8_lossy(&ops);
3492
3493        assert!(output.contains("q\n"));
3494        assert!(output.contains("Q\n"));
3495        assert!(output.contains("f\n"));
3496
3497        doc.add_page(page);
3498        assert!(doc.to_bytes().unwrap().len() > 0);
3499    }
3500
3501    #[test]
3502    fn test_e2e_watermark_workflow() {
3503        let mut ctx = GraphicsContext::new();
3504
3505        ctx.save_state();
3506        let _ = ctx.set_fill_opacity(0.2);
3507        ctx.translate(300.0, 400.0);
3508        ctx.rotate(45.0);
3509        ctx.set_font(Font::HelveticaBold, 72.0);
3510        ctx.draw_text("DRAFT", 0.0, 0.0).unwrap();
3511        ctx.restore_state();
3512
3513        let ops = ctx.generate_operations().unwrap();
3514        let output = String::from_utf8_lossy(&ops);
3515
3516        // Verify watermark structure
3517        assert!(output.contains("q\n")); // save state
3518        assert!(output.contains("Q\n")); // restore state
3519        assert!(output.contains("cm\n")); // transformations
3520        assert!(output.contains("BT\n")); // text begin
3521        assert!(output.contains("ET\n")); // text end
3522    }
3523
3524    // ====== PHASE 5: set_custom_font emits Tf operator ======
3525
3526    #[test]
3527    fn test_set_custom_font_emits_tf_operator() {
3528        let mut ctx = GraphicsContext::new();
3529        ctx.set_custom_font("NotoSansCJK", 14.0);
3530
3531        let ops = ctx.operations();
3532        assert!(
3533            ops.contains("/NotoSansCJK 14 Tf"),
3534            "set_custom_font should emit Tf operator, got: {}",
3535            ops
3536        );
3537    }
3538
3539    // ====== PHASE 3: unified custom font detection in draw_text ======
3540
3541    #[test]
3542    fn test_draw_text_uses_is_custom_font_flag() {
3543        let mut ctx = GraphicsContext::new();
3544        // Name matches a standard font, but set via set_custom_font → flag is true
3545        ctx.set_custom_font("Helvetica", 12.0);
3546        ctx.clear(); // clear the Tf operator from set_custom_font
3547
3548        ctx.draw_text("A", 10.0, 20.0).unwrap();
3549        let ops = ctx.operations();
3550        // Must use hex encoding because is_custom_font=true
3551        assert!(
3552            ops.contains("<0041> Tj"),
3553            "draw_text with is_custom_font=true should use hex, got: {}",
3554            ops
3555        );
3556    }
3557
3558    #[test]
3559    fn test_draw_text_standard_font_uses_literal() {
3560        let mut ctx = GraphicsContext::new();
3561        ctx.set_font(Font::Helvetica, 12.0);
3562        ctx.clear();
3563
3564        ctx.draw_text("Hello", 10.0, 20.0).unwrap();
3565        let ops = ctx.operations();
3566        assert!(
3567            ops.contains("(Hello) Tj"),
3568            "draw_text with standard font should use literal, got: {}",
3569            ops
3570        );
3571    }
3572
3573    // ====== PHASE 2: surrogate pairs for SMP characters ======
3574
3575    #[test]
3576    fn test_show_text_smp_character_uses_surrogate_pairs() {
3577        let mut ctx = GraphicsContext::new();
3578        ctx.set_font(Font::Custom("Emoji".to_string()), 12.0);
3579
3580        ctx.begin_text();
3581        ctx.set_text_position(0.0, 0.0);
3582        // U+1F600 (GRINNING FACE) → surrogate pair: D83D DE00
3583        ctx.show_text("\u{1F600}").unwrap();
3584        ctx.end_text();
3585
3586        let ops = ctx.operations();
3587        assert!(
3588            ops.contains("<D83DDE00> Tj"),
3589            "SMP character should use UTF-16BE surrogate pair, got: {}",
3590            ops
3591        );
3592        assert!(
3593            !ops.contains("FFFD"),
3594            "SMP character must NOT be replaced with FFFD"
3595        );
3596    }
3597
3598    // ====== PHASE 1: save/restore font state ======
3599
3600    #[test]
3601    fn test_save_restore_preserves_font_state() {
3602        let mut ctx = GraphicsContext::new();
3603        ctx.set_font(Font::Custom("CJK".to_string()), 12.0);
3604        assert!(ctx.is_custom_font);
3605        assert_eq!(ctx.current_font_name.as_deref(), Some("CJK"));
3606        assert_eq!(ctx.current_font_size, 12.0);
3607
3608        ctx.save_state();
3609        ctx.set_font(Font::Helvetica, 10.0);
3610        assert!(!ctx.is_custom_font);
3611        assert_eq!(ctx.current_font_name.as_deref(), Some("Helvetica"));
3612
3613        ctx.restore_state();
3614        assert!(
3615            ctx.is_custom_font,
3616            "is_custom_font must be restored after restore_state"
3617        );
3618        assert_eq!(ctx.current_font_name.as_deref(), Some("CJK"));
3619        assert_eq!(ctx.current_font_size, 12.0);
3620    }
3621
3622    #[test]
3623    fn test_save_restore_mixed_font_encoding() {
3624        let mut ctx = GraphicsContext::new();
3625        ctx.set_font(Font::Custom("CJK".to_string()), 12.0);
3626
3627        // Simulate table cell pattern: save → change font → text → restore → text
3628        ctx.save_state();
3629        ctx.set_font(Font::Helvetica, 10.0);
3630        ctx.begin_text();
3631        ctx.show_text("Hello").unwrap();
3632        ctx.end_text();
3633        ctx.restore_state();
3634
3635        // After restore, CJK font should be active again
3636        ctx.begin_text();
3637        ctx.show_text("你好").unwrap();
3638        ctx.end_text();
3639
3640        let ops = ctx.operations();
3641        // After restore, text must be hex-encoded (custom font restored)
3642        assert!(
3643            ops.contains("<4F60597D> Tj"),
3644            "After restore_state, CJK text should use hex encoding, got: {}",
3645            ops
3646        );
3647    }
3648
3649    #[test]
3650    fn test_graphics_state_arc_str_save_restore() {
3651        // Verifies that save/restore correctly round-trips font names stored as Arc<str>,
3652        // and that the clone is O(1) (no String allocation per save).
3653        let mut ctx = GraphicsContext::new();
3654
3655        // Set initial font
3656        ctx.set_font(Font::Custom("TestFont".to_string()), 14.0);
3657        assert_eq!(ctx.current_font_name.as_deref(), Some("TestFont"));
3658        assert!(ctx.is_custom_font);
3659
3660        // Save state, change font
3661        ctx.save_state();
3662        ctx.set_font(Font::Custom("Other".to_string()), 10.0);
3663        assert_eq!(ctx.current_font_name.as_deref(), Some("Other"));
3664
3665        // Restore: font must revert to "TestFont"
3666        ctx.restore_state();
3667        assert_eq!(
3668            ctx.current_font_name.as_deref(),
3669            Some("TestFont"),
3670            "Font name must be restored to TestFont after restore_state"
3671        );
3672        assert_eq!(ctx.current_font_size, 14.0);
3673        assert!(
3674            ctx.is_custom_font,
3675            "is_custom_font must be restored to true"
3676        );
3677
3678        // Verify the Arc<str> is actually shared (same pointer after clone)
3679        if let Some(ref arc) = ctx.current_font_name {
3680            let cloned = arc.clone();
3681            assert_eq!(arc.as_ref(), cloned.as_ref());
3682            // Arc::ptr_eq confirms O(1) clone (same backing allocation)
3683            assert!(Arc::ptr_eq(arc, &cloned));
3684        }
3685    }
3686
3687    /// RED for Phase 1 of the v2.7.0 IR refactor: with the current `String`
3688    /// emission, `set_line_width(f64::NAN)` writes literally `NaN w` into the
3689    /// content stream — invalid per ISO 32000-1 §7.3.3, same CWE-20 class as
3690    /// the colour-emission fix in 2.6.0 (issues #220, #221). Once the
3691    /// migration routes line width through `serialize_ops`, the helper
3692    /// `finite_or_zero` clamps non-finite floats to `0.0` at the emission
3693    /// boundary and the assertion below passes.
3694    #[test]
3695    fn nan_line_width_sanitised_at_emission() {
3696        let mut ctx = GraphicsContext::new();
3697        ctx.set_line_width(f64::NAN);
3698        let ops = ctx.operations();
3699        assert!(
3700            ops.contains("0.00 w\n"),
3701            "NaN line width must emit `0.00 w`, got: {ops:?}"
3702        );
3703        assert!(
3704            !ops.contains("NaN"),
3705            "`NaN` must not appear in any content-stream output, got: {ops:?}"
3706        );
3707    }
3708
3709    #[test]
3710    fn pos_inf_line_width_sanitised_at_emission() {
3711        let mut ctx = GraphicsContext::new();
3712        ctx.set_line_width(f64::INFINITY);
3713        let ops = ctx.operations();
3714        assert!(
3715            ops.contains("0.00 w\n"),
3716            "+inf line width must emit `0.00 w`, got: {ops:?}"
3717        );
3718        assert!(
3719            !ops.contains("inf"),
3720            "`inf` must not appear in any content-stream output, got: {ops:?}"
3721        );
3722    }
3723
3724    #[test]
3725    fn nan_path_coords_sanitised_at_emission() {
3726        let mut ctx = GraphicsContext::new();
3727        ctx.move_to(f64::NAN, 20.0);
3728        ctx.line_to(30.0, f64::NEG_INFINITY);
3729        ctx.rect(f64::NAN, f64::INFINITY, 100.0, f64::NEG_INFINITY);
3730        let ops = ctx.operations();
3731        assert!(
3732            !ops.contains("NaN") && !ops.contains("inf"),
3733            "non-finite floats must not appear in path operators, got: {ops:?}"
3734        );
3735        assert!(
3736            ops.contains("0.00 20.00 m\n"),
3737            "NaN x must clamp to 0.00 in `m` op, got: {ops:?}"
3738        );
3739        assert!(
3740            ops.contains("30.00 0.00 l\n"),
3741            "-inf y must clamp to 0.00 in `l` op, got: {ops:?}"
3742        );
3743        assert!(
3744            ops.contains("0.00 0.00 100.00 0.00 re\n"),
3745            "non-finite components must clamp to 0.00 in `re` op, got: {ops:?}"
3746        );
3747    }
3748
3749    // ── GFX-019: ICC + named calibrated/Lab colour-space methods ──
3750    //
3751    // The content-stream wire format these assert against is fixed by
3752    // graphics/ops.rs: `SetFill/StrokeColorSpace(name)` → `/<name> cs\n`
3753    // (fill) / `/<name> CS\n` (stroke); `SetFill/StrokeColorComponents` →
3754    // each value as `"{v:.4} "` followed by `sc\n` (fill) / `SC\n` (stroke).
3755    // A fresh GraphicsContext has no operations, so the full serialised
3756    // string equals colour-space line + components line — which also proves
3757    // the colour space is emitted BEFORE the components (correct order).
3758
3759    /// Format a components line exactly as `ops.rs` does (`"{v:.4} "` per
3760    /// value, then the painting operator + newline).
3761    fn expected_components(values: &[f64], op: &str) -> String {
3762        let mut s = String::new();
3763        for v in values {
3764            s.push_str(&format!("{v:.4} "));
3765        }
3766        s.push_str(op);
3767        s.push('\n');
3768        s
3769    }
3770
3771    #[test]
3772    fn set_fill_color_icc_emits_named_cs_then_components() {
3773        let mut ctx = GraphicsContext::new();
3774        ctx.set_fill_color_icc("ICCRGB1", vec![0.25, 0.5, 0.75]);
3775        let expected = format!(
3776            "/ICCRGB1 cs\n{}",
3777            expected_components(&[0.25, 0.5, 0.75], "sc")
3778        );
3779        assert_eq!(
3780            ctx.operations(),
3781            expected,
3782            "ICC fill must emit `/ICCRGB1 cs` then `0.2500 0.5000 0.7500 sc`"
3783        );
3784    }
3785
3786    #[test]
3787    fn set_stroke_color_icc_emits_named_cs_then_components() {
3788        let mut ctx = GraphicsContext::new();
3789        ctx.set_stroke_color_icc("ICCRGB1", vec![0.25, 0.5, 0.75]);
3790        let expected = format!(
3791            "/ICCRGB1 CS\n{}",
3792            expected_components(&[0.25, 0.5, 0.75], "SC")
3793        );
3794        assert_eq!(
3795            ctx.operations(),
3796            expected,
3797            "ICC stroke must emit `/ICCRGB1 CS` then `0.2500 0.5000 0.7500 SC`"
3798        );
3799    }
3800
3801    #[test]
3802    fn set_fill_color_icc_single_channel_gray() {
3803        let mut ctx = GraphicsContext::new();
3804        ctx.set_fill_color_icc("ICCGray1", vec![0.42]);
3805        let expected = format!("/ICCGray1 cs\n{}", expected_components(&[0.42], "sc"));
3806        assert_eq!(ctx.operations(), expected);
3807    }
3808
3809    #[test]
3810    fn set_fill_color_icc_cmyk_four_channels() {
3811        let mut ctx = GraphicsContext::new();
3812        ctx.set_fill_color_icc("ICCCmyk1", vec![0.1, 0.2, 0.3, 0.4]);
3813        let expected = format!(
3814            "/ICCCmyk1 cs\n{}",
3815            expected_components(&[0.1, 0.2, 0.3, 0.4], "sc")
3816        );
3817        assert_eq!(ctx.operations(), expected);
3818    }
3819
3820    #[test]
3821    fn set_fill_color_calibrated_named_uses_caller_name() {
3822        let color = CalibratedColor::cal_rgb([0.1, 0.2, 0.3], CalRgbColorSpace::new());
3823        let mut ctx = GraphicsContext::new();
3824        ctx.set_fill_color_calibrated_named("MyCalRgb", color.clone());
3825        let expected = format!(
3826            "/MyCalRgb cs\n{}",
3827            expected_components(&color.values(), "sc")
3828        );
3829        assert_eq!(ctx.operations(), expected);
3830    }
3831
3832    #[test]
3833    fn set_stroke_color_calibrated_named_uses_caller_name() {
3834        let color = CalibratedColor::cal_gray(0.6, CalGrayColorSpace::new());
3835        let mut ctx = GraphicsContext::new();
3836        ctx.set_stroke_color_calibrated_named("MyCalGray", color.clone());
3837        let expected = format!(
3838            "/MyCalGray CS\n{}",
3839            expected_components(&color.values(), "SC")
3840        );
3841        assert_eq!(ctx.operations(), expected);
3842    }
3843
3844    #[test]
3845    fn set_fill_color_lab_named_uses_caller_name() {
3846        let color = LabColor::with_default(50.0, 12.0, -8.0);
3847        let mut ctx = GraphicsContext::new();
3848        ctx.set_fill_color_lab_named("MyLab", color.clone());
3849        let expected = format!("/MyLab cs\n{}", expected_components(&color.values(), "sc"));
3850        assert_eq!(ctx.operations(), expected);
3851    }
3852
3853    #[test]
3854    fn set_stroke_color_lab_named_uses_caller_name() {
3855        let color = LabColor::with_default(50.0, 12.0, -8.0);
3856        let mut ctx = GraphicsContext::new();
3857        ctx.set_stroke_color_lab_named("MyLab", color.clone());
3858        let expected = format!("/MyLab CS\n{}", expected_components(&color.values(), "SC"));
3859        assert_eq!(ctx.operations(), expected);
3860    }
3861
3862    // ── Regression: the four existing methods keep their hardcoded default
3863    //    resource names after being refactored to delegate to `_named`. ──
3864
3865    #[test]
3866    fn legacy_calibrated_rgb_still_emits_calrgb1() {
3867        let color = CalibratedColor::cal_rgb([0.1, 0.2, 0.3], CalRgbColorSpace::new());
3868        let mut ctx = GraphicsContext::new();
3869        ctx.set_fill_color_calibrated(color.clone());
3870        let expected = format!(
3871            "/CalRGB1 cs\n{}",
3872            expected_components(&color.values(), "sc")
3873        );
3874        assert_eq!(ctx.operations(), expected);
3875    }
3876
3877    #[test]
3878    fn legacy_calibrated_gray_still_emits_calgray1() {
3879        let color = CalibratedColor::cal_gray(0.6, CalGrayColorSpace::new());
3880        let mut ctx = GraphicsContext::new();
3881        ctx.set_stroke_color_calibrated(color.clone());
3882        let expected = format!(
3883            "/CalGray1 CS\n{}",
3884            expected_components(&color.values(), "SC")
3885        );
3886        assert_eq!(ctx.operations(), expected);
3887    }
3888
3889    #[test]
3890    fn legacy_lab_still_emits_lab1() {
3891        let color = LabColor::with_default(50.0, 12.0, -8.0);
3892        let mut ctx = GraphicsContext::new();
3893        ctx.set_fill_color_lab(color.clone());
3894        let expected = format!("/Lab1 cs\n{}", expected_components(&color.values(), "sc"));
3895        assert_eq!(ctx.operations(), expected);
3896    }
3897
3898    #[test]
3899    fn legacy_lab_stroke_still_emits_lab1() {
3900        let color = LabColor::with_default(50.0, 12.0, -8.0);
3901        let mut ctx = GraphicsContext::new();
3902        ctx.set_stroke_color_lab(color.clone());
3903        let expected = format!("/Lab1 CS\n{}", expected_components(&color.values(), "SC"));
3904        assert_eq!(ctx.operations(), expected);
3905    }
3906
3907    // The empty-components guard is a `debug_assert!`, compiled out in release
3908    // builds (`cargo test --release`, tarpaulin coverage). Gating these tests to
3909    // `debug_assertions` keeps them honest: they only run where the assert fires.
3910    // Without the gate they fail in release with "did not panic as expected".
3911    #[test]
3912    #[cfg(debug_assertions)]
3913    #[should_panic(expected = "ICC fill colour components must not be empty")]
3914    fn set_fill_color_icc_empty_components_panics_in_debug() {
3915        // An empty component list would emit a bare `sc` operator with no
3916        // operands, invalid per ISO 32000-1 §8.6.8. The debug_assert guards
3917        // the caller-supplied ICC path (calibrated/Lab cannot reach this).
3918        let mut ctx = GraphicsContext::new();
3919        ctx.set_fill_color_icc("ICCRGB1", vec![]);
3920    }
3921
3922    #[test]
3923    #[cfg(debug_assertions)]
3924    #[should_panic(expected = "ICC stroke colour components must not be empty")]
3925    fn set_stroke_color_icc_empty_components_panics_in_debug() {
3926        let mut ctx = GraphicsContext::new();
3927        ctx.set_stroke_color_icc("ICCRGB1", vec![]);
3928    }
3929}