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