Skip to main content

stet_pdf_reader/content/
graphics_state.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! PDF graphics state for content stream interpretation.
6
7use stet_fonts::geometry::{Matrix, PsPath};
8use stet_graphics::color::{DashPattern, DeviceColor, FillRule, LineCap, LineJoin};
9use stet_graphics::device::{
10    BgUcrState, FillParams, HalftoneState, IccColor, SpotColor, StrokeParams, TransferState,
11};
12use stet_graphics::display_list::{DisplayList, SoftMaskSubtype};
13
14/// Wrapper for a shading pattern's display list (Debug-friendly).
15#[derive(Clone)]
16pub struct ShadingPatternDL(pub DisplayList);
17
18impl std::fmt::Debug for ShadingPatternDL {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        f.debug_struct("ShadingPatternDL")
21            .field("elements", &self.0.len())
22            .finish()
23    }
24}
25
26/// A resolved tiling pattern ready to be applied at fill/stroke time.
27#[derive(Clone)]
28pub struct TilingPattern {
29    /// Pre-rendered display list for a single tile.
30    pub tile: DisplayList,
31    /// Bounding box of one tile in pattern space.
32    pub bbox: [f64; 4],
33    /// Horizontal step between tile origins.
34    pub x_step: f64,
35    /// Vertical step between tile origins.
36    pub y_step: f64,
37    /// Combined pattern matrix (CTM x pattern_matrix at scn time).
38    pub pattern_matrix: Matrix,
39    /// Paint type: 1 = colored, 2 = uncolored.
40    pub paint_type: i32,
41    /// Unique pattern ID for dedup.
42    pub pattern_id: u32,
43    /// True when the PDF pattern matrix had a Y-flip (negative d component).
44    pub flip_tile_y: bool,
45}
46
47impl std::fmt::Debug for TilingPattern {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.debug_struct("TilingPattern")
50            .field("bbox", &self.bbox)
51            .field("x_step", &self.x_step)
52            .field("y_step", &self.y_step)
53            .field("paint_type", &self.paint_type)
54            .field("pattern_id", &self.pattern_id)
55            .finish()
56    }
57}
58
59/// A resolved soft mask from ExtGState /SMask.
60#[derive(Clone)]
61pub struct SoftMask {
62    /// Pre-rendered mask form display list.
63    pub mask_list: DisplayList,
64    /// How to extract the mask (alpha or luminosity).
65    pub subtype: SoftMaskSubtype,
66    /// Device-space bounding box.
67    pub bbox: [f64; 4],
68    /// Backdrop color for luminosity masks (RGB, 0.0–1.0).
69    pub backdrop_color: Option<[f64; 3]>,
70    /// Whether the mask values should be inverted (from /TR `{1 exch sub}`).
71    pub transfer_invert: bool,
72    /// Whether the mask form contained nested soft mask scopes (gs-set SMask
73    /// inside the form that was flushed). When true, the renderer must
74    /// composite semi-transparent pixels onto the backdrop before extracting
75    /// luminosity, since the alpha encodes mask modulation from nested masks.
76    pub has_nested_mask_scope: bool,
77}
78
79impl std::fmt::Debug for SoftMask {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.debug_struct("SoftMask")
82            .field("subtype", &self.subtype)
83            .field("bbox", &self.bbox)
84            .finish()
85    }
86}
87
88/// Reference to a color space (resolved lazily from resources).
89#[derive(Clone, Debug)]
90pub enum ColorSpaceRef {
91    DeviceGray,
92    DeviceRGB,
93    DeviceCMYK,
94    /// Named color space from page resources (e.g. ICCBased, CalRGB, Indexed, etc.).
95    Named(Vec<u8>),
96}
97
98impl ColorSpaceRef {
99    /// Number of components for the simple device color spaces.
100    pub fn num_components(&self) -> Option<usize> {
101        match self {
102            Self::DeviceGray => Some(1),
103            Self::DeviceRGB => Some(3),
104            Self::DeviceCMYK => Some(4),
105            Self::Named(_) => None,
106        }
107    }
108}
109
110/// PDF graphics state — self-contained, no VM/Context dependencies.
111#[derive(Clone, Debug)]
112pub struct PdfGraphicsState {
113    pub ctm: Matrix,
114    pub fill_color: DeviceColor,
115    pub stroke_color: DeviceColor,
116    pub line_width: f64,
117    pub line_cap: LineCap,
118    pub line_join: LineJoin,
119    pub miter_limit: f64,
120    pub dash_pattern: DashPattern,
121    pub rendering_intent: u8,
122    pub stroke_adjust: bool,
123    pub overprint: bool,
124    pub overprint_stroke: bool,
125    /// Overprint mode: 0 = all components painted, 1 = only non-zero components painted.
126    pub overprint_mode: i32,
127    /// True when the most recent ExtGState dict set BOTH /OPM and (/op or /OP)
128    /// together. Strict OPM-1 semantics (zero source preserves backdrop) apply
129    /// only when this is true; otherwise an inherited OPM=1 paired with a
130    /// separately-set /op falls back to legacy knockout semantics for an
131    /// all-zero CMYK source — matching Adobe Acrobat's behavior on real-world
132    /// PDFs that set /op in isolation without re-asserting /OPM.
133    pub opm_paired: bool,
134    /// CMYK channel bitmask for fill overprint (which channels the current fill color space paints).
135    pub fill_painted_channels: u8,
136    /// True when fill color space is DeviceCMYK or ICCBased(4) — OPM 1 only applies to these.
137    pub fill_is_device_cmyk: bool,
138    /// CMYK channel bitmask for stroke overprint.
139    pub stroke_painted_channels: u8,
140    /// True when stroke color space is DeviceCMYK or ICCBased(4) — OPM 1 only applies to these.
141    pub stroke_is_device_cmyk: bool,
142    pub flatness: f64,
143    pub fill_color_space: ColorSpaceRef,
144    pub stroke_color_space: ColorSpaceRef,
145    /// Pending clip: set by W/W*, applied after next paint op.
146    pub pending_clip: Option<(PsPath, FillRule)>,
147    /// Current clip path (most recent, for bbox estimation).
148    pub clip_path: Option<PsPath>,
149    /// Full stack of active clip paths for accurate restoration on Q.
150    /// Each entry is a (path, fill_rule) pair pushed by W/W*/push_bbox_clip.
151    /// When Q detects a clip change, it pushes InitClip + replays all saved clips.
152    pub clip_stack: Vec<(PsPath, FillRule)>,
153    /// Clip version counter — incremented on each W/W* application.
154    pub clip_path_version: u32,
155    pub fill_alpha: f64,
156    pub stroke_alpha: f64,
157    /// Native Separation/DeviceN fill color (preserved for PDF output round-trip).
158    /// `None` when the current fill color space is a device space.
159    pub fill_spot_color: Option<SpotColor>,
160    /// Native Separation/DeviceN stroke color (preserved for PDF output round-trip).
161    /// `None` when the current stroke color space is a device space.
162    pub stroke_spot_color: Option<SpotColor>,
163    /// ICCBased fill color (preserved for PDF output round-trip). `None`
164    /// for device color spaces and for Separation/DeviceN paints.
165    pub fill_icc_color: Option<IccColor>,
166    /// ICCBased stroke color (preserved for PDF output round-trip).
167    pub stroke_icc_color: Option<IccColor>,
168    /// True when fill color is Separation/None (produces no visible marks).
169    pub fill_is_none: bool,
170    /// True when stroke color is Separation/None (produces no visible marks).
171    pub stroke_is_none: bool,
172    /// Blend mode (0=Normal, 1=Multiply, ..., 11=Exclusion).
173    pub blend_mode: u8,
174    /// PDF `AIS` (alpha-is-shape) from ExtGState. Default false.
175    pub alpha_is_shape: bool,
176    /// PDF `TK` (text knockout) from ExtGState. Default true.
177    pub text_knockout: bool,
178    // Text state
179    pub text_matrix: Matrix,
180    pub text_line_matrix: Matrix,
181    pub font_size: f64,
182    pub char_spacing: f64,
183    pub word_spacing: f64,
184    pub text_leading: f64,
185    pub text_rise: f64,
186    /// Horizontal scaling factor (Tz / 100). Default 1.0 = 100%.
187    pub horizontal_scaling: f64,
188    pub text_rendering_mode: i32,
189    pub text_font_name: Vec<u8>,
190    /// Active tiling pattern for fill (set by scn with Pattern color space).
191    pub fill_pattern: Option<TilingPattern>,
192    /// Active shading pattern for fill (PatternType 2).
193    /// Stored as `Option<Box<DisplayList>>` so PdfGraphicsState can derive Debug
194    /// (DisplayList doesn't implement Debug).
195    pub fill_shading_pattern: Option<Box<ShadingPatternDL>>,
196    /// Active tiling pattern for stroke (set by SCN with Pattern color space).
197    pub stroke_pattern: Option<TilingPattern>,
198    /// Active shading pattern for stroke (PatternType 2).
199    pub stroke_shading_pattern: Option<Box<ShadingPatternDL>>,
200    /// Counter for unique pattern IDs.
201    pub next_pattern_id: u32,
202    /// Transfer function state.
203    pub transfer: TransferState,
204    /// Active soft mask from ExtGState /SMask.
205    pub soft_mask: Option<SoftMask>,
206    /// Generation counter: incremented each time a new SMask is set via gs.
207    /// Used by the Q handler to detect SMask changes within a q/Q block.
208    pub smask_gen: u64,
209}
210
211impl PdfGraphicsState {
212    /// Create a new graphics state with PDF defaults.
213    pub fn new(initial_ctm: Matrix) -> Self {
214        Self {
215            ctm: initial_ctm,
216            fill_color: DeviceColor::black(),
217            stroke_color: DeviceColor::black(),
218            line_width: 1.0,
219            line_cap: LineCap::Butt,
220            line_join: LineJoin::Miter,
221            miter_limit: 10.0,
222            dash_pattern: DashPattern::solid(),
223            rendering_intent: 0,
224            stroke_adjust: false,
225            overprint: false,
226            overprint_stroke: false,
227            overprint_mode: 0,
228            opm_paired: false,
229            fill_painted_channels: 0,
230            fill_is_device_cmyk: false,
231            stroke_painted_channels: 0,
232            stroke_is_device_cmyk: false,
233            flatness: 1.0,
234            fill_color_space: ColorSpaceRef::DeviceGray,
235            stroke_color_space: ColorSpaceRef::DeviceGray,
236            pending_clip: None,
237            clip_path: None,
238            clip_stack: Vec::new(),
239            clip_path_version: 0,
240            fill_alpha: 1.0,
241            stroke_alpha: 1.0,
242            fill_spot_color: None,
243            stroke_spot_color: None,
244            fill_icc_color: None,
245            stroke_icc_color: None,
246            fill_is_none: false,
247            stroke_is_none: false,
248            blend_mode: 0,
249            alpha_is_shape: false,
250            text_knockout: true,
251            text_matrix: Matrix::identity(),
252            text_line_matrix: Matrix::identity(),
253            font_size: 0.0,
254            char_spacing: 0.0,
255            word_spacing: 0.0,
256            text_leading: 0.0,
257            text_rise: 0.0,
258            horizontal_scaling: 1.0,
259            text_rendering_mode: 0,
260            text_font_name: Vec::new(),
261            fill_pattern: None,
262            fill_shading_pattern: None,
263            stroke_pattern: None,
264            stroke_shading_pattern: None,
265            next_pattern_id: 0,
266            transfer: TransferState::default(),
267            soft_mask: None,
268            smask_gen: 0,
269        }
270    }
271
272    /// Build FillParams from current state, applying transfer functions to color.
273    pub fn fill_params(&self, fill_rule: FillRule) -> FillParams {
274        let color = if self.transfer.has_functions() {
275            super::apply_transfer_to_color(&self.fill_color, &self.transfer)
276        } else {
277            self.fill_color.clone()
278        };
279        FillParams {
280            color,
281            fill_rule,
282            ctm: Matrix::identity(),
283            is_text_glyph: false,
284            overprint: self.overprint,
285            overprint_mode: self.overprint_mode,
286            opm_paired: self.opm_paired,
287            painted_channels: self.fill_painted_channels,
288            is_device_cmyk: self.fill_is_device_cmyk,
289            spot_color: self.fill_spot_color.clone(),
290            icc_color: self.fill_icc_color.clone(),
291            rendering_intent: self.rendering_intent,
292            transfer: self.transfer.clone(),
293            halftone: HalftoneState::default(),
294            bg_ucr: BgUcrState::default(),
295            alpha: if self.fill_is_none {
296                0.0
297            } else {
298                self.fill_alpha
299            },
300            blend_mode: self.blend_mode,
301            alpha_is_shape: self.alpha_is_shape,
302        }
303    }
304
305    /// Build StrokeParams from current state with CTM scale applied, applying transfer to color.
306    pub fn stroke_params(&self) -> StrokeParams {
307        let scale = self.ctm_scale_factor();
308        let scaled_dash = DashPattern {
309            array: self.dash_pattern.array.iter().map(|d| d * scale).collect(),
310            offset: self.dash_pattern.offset * scale,
311        };
312        let color = if self.transfer.has_functions() {
313            super::apply_transfer_to_color(&self.stroke_color, &self.transfer)
314        } else {
315            self.stroke_color.clone()
316        };
317        StrokeParams {
318            color,
319            line_width: self.line_width * scale,
320            line_cap: self.line_cap,
321            line_join: self.line_join,
322            miter_limit: self.miter_limit,
323            dash_pattern: scaled_dash,
324            ctm: Matrix::identity(),
325            stroke_adjust: self.stroke_adjust,
326            is_text_glyph: false,
327            overprint: self.overprint_stroke,
328            overprint_mode: self.overprint_mode,
329            opm_paired: self.opm_paired,
330            painted_channels: self.stroke_painted_channels,
331            is_device_cmyk: self.stroke_is_device_cmyk,
332            spot_color: self.stroke_spot_color.clone(),
333            icc_color: self.stroke_icc_color.clone(),
334            rendering_intent: self.rendering_intent,
335            transfer: self.transfer.clone(),
336            halftone: HalftoneState::default(),
337            bg_ucr: BgUcrState::default(),
338            alpha: if self.stroke_is_none {
339                0.0
340            } else {
341                self.stroke_alpha
342            },
343            blend_mode: self.blend_mode,
344            alpha_is_shape: self.alpha_is_shape,
345        }
346    }
347
348    /// Build StrokeParams with the CTM applied by the renderer (not pre-scaled).
349    /// Used for correct anisotropic strokes where the CTM has non-uniform scaling.
350    pub fn stroke_params_with_ctm(&self) -> StrokeParams {
351        let color = if self.transfer.has_functions() {
352            super::apply_transfer_to_color(&self.stroke_color, &self.transfer)
353        } else {
354            self.stroke_color.clone()
355        };
356        StrokeParams {
357            color,
358            line_width: self.line_width,
359            line_cap: self.line_cap,
360            line_join: self.line_join,
361            miter_limit: self.miter_limit,
362            dash_pattern: self.dash_pattern.clone(),
363            ctm: Matrix::identity(), // caller sets this
364            stroke_adjust: self.stroke_adjust,
365            is_text_glyph: false,
366            overprint: self.overprint_stroke,
367            overprint_mode: self.overprint_mode,
368            opm_paired: self.opm_paired,
369            painted_channels: self.stroke_painted_channels,
370            is_device_cmyk: self.stroke_is_device_cmyk,
371            spot_color: self.stroke_spot_color.clone(),
372            icc_color: self.stroke_icc_color.clone(),
373            rendering_intent: self.rendering_intent,
374            transfer: self.transfer.clone(),
375            halftone: HalftoneState::default(),
376            bg_ucr: BgUcrState::default(),
377            alpha: if self.stroke_is_none {
378                0.0
379            } else {
380                self.stroke_alpha
381            },
382            blend_mode: self.blend_mode,
383            alpha_is_shape: self.alpha_is_shape,
384        }
385    }
386
387    /// CTM scale factor: sqrt(a^2 + b^2).
388    pub fn ctm_scale_factor(&self) -> f64 {
389        (self.ctm.a * self.ctm.a + self.ctm.b * self.ctm.b).sqrt()
390    }
391}