Skip to main content

oxidize_pdf/graphics/
mod.rs

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