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