Skip to main content

rasterrocket_render/
state.rs

1//! Graphics state and save/restore stack.
2//!
3//! [`GraphicsState`] is the Rust equivalent of `SplashState` from
4//! `splash/SplashState.h/.cc`, with the linked-list `*next` pointer replaced
5//! by a [`StateStack`] that owns a `Vec<GraphicsState>`.
6//!
7//! ## Default values
8//!
9//! All defaults match the `SplashState` constructor exactly:
10//! - CTM = identity `[1, 0, 0, 1, 0, 0]`
11//! - Stroke/fill alpha = 1.0
12//! - Line cap = Butt, line join = Miter, miter limit = 10.0, flatness = 1.0
13//! - All transfer LUTs = identity
14//! - Overprint mask = `0xFFFF_FFFF`
15//! - Clip rect = `[0, 0, width-0.001, height-0.001]` (intentional sub-pixel inset)
16//!
17//! ## `set_transfer` semantics
18//!
19//! Matches `SplashState::setTransfer`: the CMYK and `DeviceN`[0..4] LUTs are
20//! derived from the **inverted** RGB/gray LUTs *before* the RGB/gray LUTs are
21//! overwritten. See `SplashState.cc` for the detailed rationale.
22
23use crate::bitmap::AnyBitmap;
24use crate::clip::Clip;
25use crate::types::{LineCap, LineJoin, SPOT_NCOMPS, ScreenParams};
26use color::TransferLut;
27
28// ── GraphicsState ─────────────────────────────────────────────────────────────
29
30/// The complete graphics state for one rendering context.
31///
32/// Patterns are stubbed as `()` for Phase 1; Phase 2 replaces them with
33/// `Box<dyn Pattern>`.
34pub struct GraphicsState {
35    /// Current transformation matrix in column-vector 2-D affine form: `[a, b, c, d, e, f]`.
36    pub matrix: [f64; 6],
37
38    // Patterns (Phase 2 placeholder).
39    // pub stroke_pattern: Box<dyn Pattern>,
40    // pub fill_pattern:   Box<dyn Pattern>,
41    /// Halftone screen parameters controlling frequency, angle, and spot function.
42    pub screen: ScreenParams,
43
44    /// Opacity for stroking operations. `0.0` = fully transparent, `1.0` = fully opaque.
45    pub stroke_alpha: f64,
46    /// Opacity for fill operations. `0.0` = fully transparent, `1.0` = fully opaque.
47    pub fill_alpha: f64,
48    /// Effective stroke alpha after multiplying in pattern alpha (used when `multiply_pattern_alpha` is set).
49    pub pattern_stroke_alpha: f64,
50    /// Effective fill alpha after multiplying in pattern alpha (used when `multiply_pattern_alpha` is set).
51    pub pattern_fill_alpha: f64,
52
53    /// Stroke line width in user-space units; must be ≥ 0.
54    pub line_width: f64,
55    /// Style of line end caps (butt, round, or square).
56    pub line_cap: LineCap,
57    /// Style of line joins (miter, round, or bevel).
58    pub line_join: LineJoin,
59    /// Maximum ratio of miter length to line width before a miter join is beveled; default 10.0.
60    pub miter_limit: f64,
61    /// Maximum permitted distance between the path and the approximating line segments; default 1.0.
62    pub flatness: f64,
63
64    /// Dash pattern array: alternating on/off lengths in user-space units.
65    pub line_dash: Vec<f64>,
66    /// Offset into the dash pattern at which stroking begins.
67    pub line_dash_phase: f64,
68
69    /// Active clipping region; updated by clip operators.
70    pub clip: Clip,
71
72    /// Soft mask bitmap (None if no soft mask is active).
73    pub soft_mask: Option<Box<AnyBitmap>>,
74
75    /// PDF overprint mode: `0` = `CompatibleOverprint`, `1` = `IsolatePaint`.
76    pub overprint_mode: i32,
77
78    /// Transfer LUTs — RGB channels (R=0, G=1, B=2).
79    pub rgb_transfer: [TransferLut; 3],
80    /// Transfer LUT for the gray channel.
81    pub gray_transfer: TransferLut,
82    /// Transfer LUTs — CMYK channels (C=0, M=1, Y=2, K=3).
83    pub cmyk_transfer: [TransferLut; 4],
84    /// Transfer LUTs — `DeviceN` channels (indices `0..SPOT_NCOMPS+4` = 8).
85    pub device_n_transfer: Vec<[u8; 256]>,
86
87    /// Bitmask of color components that participate in overprinting; default `0xFFFF_FFFF` (all).
88    pub overprint_mask: u32,
89
90    /// Whether the soft mask should be deleted on the next state restore.
91    /// New states cloned for an `XObject` explicitly clear this so child
92    /// renders do not inherit the parent's deletion intent.
93    pub delete_soft_mask: bool,
94}
95
96impl GraphicsState {
97    /// Construct the default state for a new page.
98    ///
99    /// `clip` is set to `[0, 0, width-0.001, height-0.001]` — the intentional
100    /// 0.001 inset matches `SplashState` constructor and avoids edge-pixel issues.
101    ///
102    /// # Panics
103    ///
104    /// Panics (in debug builds only) if `width` or `height` is zero.  A zero
105    /// dimension would produce a negative clip bound (`0.0 - 0.001 = -0.001`),
106    /// which is meaningless and almost certainly a caller bug.
107    #[must_use]
108    pub fn new(width: u32, height: u32, vector_antialias: bool) -> Self {
109        debug_assert!(width > 0, "GraphicsState::new: width must be > 0");
110        debug_assert!(height > 0, "GraphicsState::new: height must be > 0");
111        let clip = Clip::new(
112            0.0,
113            0.0,
114            f64::from(width) - 0.001,
115            f64::from(height) - 0.001,
116            vector_antialias,
117        );
118        Self {
119            matrix: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
120            screen: ScreenParams::default(),
121            stroke_alpha: 1.0,
122            fill_alpha: 1.0,
123            pattern_stroke_alpha: 1.0,
124            pattern_fill_alpha: 1.0,
125            line_width: 1.0,
126            line_cap: LineCap::Butt,
127            line_join: LineJoin::Miter,
128            miter_limit: 10.0,
129            flatness: 1.0,
130            line_dash: Vec::new(),
131            line_dash_phase: 0.0,
132            clip,
133            soft_mask: None,
134            overprint_mode: 0,
135            rgb_transfer: [
136                TransferLut::IDENTITY,
137                TransferLut::IDENTITY,
138                TransferLut::IDENTITY,
139            ],
140            gray_transfer: TransferLut::IDENTITY,
141            cmyk_transfer: [
142                TransferLut::IDENTITY,
143                TransferLut::IDENTITY,
144                TransferLut::IDENTITY,
145                TransferLut::IDENTITY,
146            ],
147            device_n_transfer: vec![TransferLut::IDENTITY.0; SPOT_NCOMPS + 4],
148            overprint_mask: 0xFFFF_FFFF,
149            delete_soft_mask: false,
150        }
151    }
152
153    /// Apply new RGB and gray transfer functions, deriving CMYK and `DeviceN`[0..4]
154    /// from the **inverted** current RGB/gray values before overwriting them.
155    ///
156    /// Matches `SplashState::setTransfer` in `SplashState.cc` exactly.
157    ///
158    /// ## Derivation
159    ///
160    /// CMYK uses a subtractive colour model: a CMYK value of 0 means "no ink"
161    /// (full brightness) and 255 means "full ink" (zero brightness).  The
162    /// relationship to the existing RGB transfer LUT is therefore:
163    ///
164    /// ```text
165    /// Step 1 – invert the index:   look up rgb_transfer_R at position (255 - i)
166    ///                               to obtain the complemented input value.
167    /// Step 2 – invert the result:  subtract from 255 to flip from additive to
168    ///                               subtractive space.
169    ///
170    /// cmykTransferC[i] = 255 - rgb_transfer_R[255 - i]   ← C mapped from R
171    /// cmykTransferM[i] = 255 - rgb_transfer_G[255 - i]   ← M mapped from G
172    /// cmykTransferY[i] = 255 - rgb_transfer_B[255 - i]   ← Y mapped from B
173    /// cmykTransferK[i] = 255 - gray_transfer[255 - i]    ← K mapped from gray
174    /// deviceNTransfer[0..=3][i]  = same as CMYK (C/M/Y/K), respectively
175    /// ```
176    ///
177    /// Only after the CMYK/DeviceN tables have been built are `rgb_transfer` and
178    /// `gray_transfer` overwritten with the new `r`, `g`, `b`, `gray` LUTs.
179    pub fn set_transfer(&mut self, r: &[u8; 256], g: &[u8; 256], b: &[u8; 256], gray: &[u8; 256]) {
180        // ── Step 1: Snapshot CMYK derivations from the CURRENT (pre-overwrite) LUTs ──
181        //
182        // Each entry uses invert_complement: cmyk[i] = 255 - rgb[255 - i].
183        // This converts the additive RGB transfer into the subtractive CMYK
184        // transfer in one pass, before the RGB/gray LUTs are replaced below.
185        let mut cc = [0u8; 256];
186        let mut cm = [0u8; 256];
187        let mut cy = [0u8; 256];
188        let mut ck = [0u8; 256];
189        for i in 0usize..256 {
190            cc[i] = 255 - self.rgb_transfer[0].0[255 - i]; // C ← invert_complement(R)
191            cm[i] = 255 - self.rgb_transfer[1].0[255 - i]; // M ← invert_complement(G)
192            cy[i] = 255 - self.rgb_transfer[2].0[255 - i]; // Y ← invert_complement(B)
193            ck[i] = 255 - self.gray_transfer.0[255 - i]; // K ← invert_complement(gray)
194        }
195        // DeviceN channels 0–3 mirror CMYK C/M/Y/K exactly — copy before the
196        // arrays are moved into the cmyk_transfer LUTs below.
197        self.device_n_transfer[0] = cc;
198        self.device_n_transfer[1] = cm;
199        self.device_n_transfer[2] = cy;
200        self.device_n_transfer[3] = ck;
201        self.cmyk_transfer = [
202            TransferLut(cc),
203            TransferLut(cm),
204            TransferLut(cy),
205            TransferLut(ck),
206        ];
207
208        // ── Step 2: Overwrite RGB and gray with the caller-supplied LUTs ──
209        self.rgb_transfer[0] = TransferLut(*r);
210        self.rgb_transfer[1] = TransferLut(*g);
211        self.rgb_transfer[2] = TransferLut(*b);
212        self.gray_transfer = TransferLut(*gray);
213    }
214
215    /// Clone this state for `save()`.
216    ///
217    /// Clip scanners are shared via `Arc` (matching C++ `shared_ptr` semantics).
218    /// The soft mask is NOT inherited — the new state starts with `soft_mask = None`.
219    #[must_use]
220    pub fn save_clone(&self) -> Self {
221        Self {
222            matrix: self.matrix,
223            screen: self.screen,
224            stroke_alpha: self.stroke_alpha,
225            fill_alpha: self.fill_alpha,
226            pattern_stroke_alpha: self.pattern_stroke_alpha,
227            pattern_fill_alpha: self.pattern_fill_alpha,
228            line_width: self.line_width,
229            line_cap: self.line_cap,
230            line_join: self.line_join,
231            miter_limit: self.miter_limit,
232            flatness: self.flatness,
233            line_dash: self.line_dash.clone(),
234            line_dash_phase: self.line_dash_phase,
235            clip: self.clip.clone_shared(),
236            soft_mask: None, // new state does not own the parent's soft mask
237            overprint_mode: self.overprint_mode,
238            rgb_transfer: self.rgb_transfer.clone(),
239            gray_transfer: self.gray_transfer.clone(),
240            cmyk_transfer: self.cmyk_transfer.clone(),
241            device_n_transfer: self.device_n_transfer.clone(),
242            overprint_mask: self.overprint_mask,
243            // The new state should not inherit delete_soft_mask from the parent.
244            delete_soft_mask: false,
245        }
246    }
247
248    /// Borrow the transfer tables as a [`TransferSet`] for use in the pipe.
249    ///
250    /// The returned `TransferSet` borrows from `self` and is valid for the lifetime
251    /// of this `GraphicsState`.
252    #[must_use]
253    pub fn transfer_set(&self) -> TransferSet<'_> {
254        TransferSet {
255            rgb: [
256                self.rgb_transfer[0].as_array(),
257                self.rgb_transfer[1].as_array(),
258                self.rgb_transfer[2].as_array(),
259            ],
260            gray: self.gray_transfer.as_array(),
261            cmyk: [
262                self.cmyk_transfer[0].as_array(),
263                self.cmyk_transfer[1].as_array(),
264                self.cmyk_transfer[2].as_array(),
265                self.cmyk_transfer[3].as_array(),
266            ],
267            device_n: &self.device_n_transfer,
268        }
269    }
270}
271
272// ── TransferSet ───────────────────────────────────────────────────────────────
273
274/// Borrowed references to all transfer LUTs from a [`GraphicsState`].
275///
276/// Constructed via [`GraphicsState::transfer_set`] and stored in [`crate::PipeState`].
277/// Avoids cloning the tables for each paint operation.
278#[derive(Copy, Clone, Debug)]
279pub struct TransferSet<'a> {
280    /// RGB transfer tables: `[R, G, B]`, each 256 bytes.
281    pub rgb: [&'a [u8; 256]; 3],
282    /// Gray transfer table, 256 bytes.
283    pub gray: &'a [u8; 256],
284    /// CMYK transfer tables: `[C, M, Y, K]`, each 256 bytes.
285    pub cmyk: [&'a [u8; 256]; 4],
286    /// `DeviceN` transfer tables: `SPOT_NCOMPS + 4` tables of 256 bytes each, as a slice.
287    ///
288    /// Shape is intentionally `&[[u8; 256]]` (slice of owned arrays) rather
289    /// than `&[&[u8; 256]]` (slice of references).  Production stores these
290    /// as `Vec<[u8; 256]>` on `GraphicsState` because custom transfer
291    /// functions from PDF `/TR` produce owned tables; a slice-of-references
292    /// view would require either rebuilding a `Vec<&[u8; 256]>` per
293    /// `transfer_set()` call (per-paint allocation) or maintaining a
294    /// parallel index that stays in sync with the owned storage.  The
295    /// owned-array shape keeps the hot path allocation-free at the cost
296    /// of needing a separate `static DN: [[u8; 256]; 8]` declaration in
297    /// each test site.
298    pub device_n: &'a [[u8; 256]],
299}
300
301impl TransferSet<'_> {
302    /// Return `true` if all three RGB transfer tables are the identity `v → v`.
303    ///
304    /// Used by the AA fast path to skip the transfer step entirely when it would
305    /// be a no-op.  Checking pointer equality against the static identity table
306    /// is O(1) and covers the common case; a full element-by-element comparison
307    /// is the fallback for non-static tables that happen to be identity.
308    #[must_use]
309    pub fn is_identity_rgb(&self) -> bool {
310        use color::TransferLut;
311        let id = TransferLut::IDENTITY.as_array();
312        self.rgb.iter().all(|lut| {
313            // Fast path: same pointer (static identity table).
314            std::ptr::eq(*lut, id) || *lut == id
315        })
316    }
317
318    /// Return a `TransferSet` backed by identity (pass-through) arrays.
319    ///
320    /// Useful in tests and for the initial no-transfer state.
321    /// The returned value borrows from static memory.
322    #[must_use]
323    pub fn identity_rgb() -> TransferSet<'static> {
324        use color::TransferLut;
325        // SAFETY: TransferLut::IDENTITY is a static constant; its inner [u8; 256]
326        // reference is valid for 'static.
327        TransferSet {
328            rgb: [
329                TransferLut::IDENTITY.as_array(),
330                TransferLut::IDENTITY.as_array(),
331                TransferLut::IDENTITY.as_array(),
332            ],
333            gray: TransferLut::IDENTITY.as_array(),
334            cmyk: [
335                TransferLut::IDENTITY.as_array(),
336                TransferLut::IDENTITY.as_array(),
337                TransferLut::IDENTITY.as_array(),
338                TransferLut::IDENTITY.as_array(),
339            ],
340            device_n: {
341                // A static identity table for all 8 DeviceN channels.
342                static DN: [[u8; 256]; 8] = [TransferLut::IDENTITY.0; 8];
343                &DN
344            },
345        }
346    }
347}
348
349// ── StateStack ────────────────────────────────────────────────────────────────
350
351/// A Vec-based save/restore stack of [`GraphicsState`] values.
352///
353/// Replaces the raw-pointer linked list (`SplashState *next`) in the C++
354/// original.
355///
356/// ## Stack invariant
357///
358/// The stack **always** contains at least one entry — the initial state passed
359/// to [`StateStack::new`].  This invariant is established by the constructor
360/// and maintained by every method:
361///
362/// - [`save`](StateStack::save) only pushes (depth grows).
363/// - [`restore`](StateStack::restore) refuses to pop the last entry and signals
364///   this via its `bool` return value.
365///
366/// Because the invariant holds unconditionally, the `.last()` / `.last_mut()`
367/// calls in [`current`](StateStack::current), [`current_mut`](StateStack::current_mut),
368/// and [`save`](StateStack::save) can never return `None`.  The `.expect()`
369/// calls there exist solely to surface a bug if the invariant is ever broken
370/// during development.
371pub struct StateStack {
372    stack: Vec<GraphicsState>,
373}
374
375impl StateStack {
376    /// Create a new stack with `initial` as the sole (bottom) state.
377    ///
378    /// After construction, [`depth`](StateStack::depth) returns `1`.
379    #[must_use]
380    pub fn new(initial: GraphicsState) -> Self {
381        Self {
382            stack: vec![initial],
383        }
384    }
385
386    /// Borrow the current (top) state.
387    ///
388    /// # Panics
389    ///
390    /// Never panics in correct code — the stack invariant guarantees at least
391    /// one entry at all times.  The `expect` is a development-time tripwire.
392    #[must_use]
393    pub fn current(&self) -> &GraphicsState {
394        // SAFETY: stack invariant guarantees len >= 1; .last() cannot be None.
395        self.stack
396            .last()
397            .expect("StateStack invariant violated: stack is empty")
398    }
399
400    /// Mutably borrow the current state.
401    ///
402    /// # Panics
403    ///
404    /// Never panics in correct code — the stack invariant guarantees at least
405    /// one entry at all times.  The `expect` is a development-time tripwire.
406    pub fn current_mut(&mut self) -> &mut GraphicsState {
407        // SAFETY: stack invariant guarantees len >= 1; .last_mut() cannot be None.
408        self.stack
409            .last_mut()
410            .expect("StateStack invariant violated: stack is empty")
411    }
412
413    /// Push a clone of the current state (PDF `q` operator).
414    ///
415    /// # Panics
416    ///
417    /// Never panics in correct code — the stack invariant guarantees at least
418    /// one entry at all times.  The `expect` is a development-time tripwire.
419    pub fn save(&mut self) {
420        // SAFETY: stack invariant guarantees len >= 1; .last() cannot be None.
421        let cloned = self
422            .stack
423            .last()
424            .expect("StateStack invariant violated: stack is empty")
425            .save_clone();
426        self.stack.push(cloned);
427    }
428
429    /// Pop the top state (PDF `Q` operator).
430    ///
431    /// Returns `true` on success.
432    ///
433    /// Returns `false` — **without modifying the stack** — when the stack is at
434    /// depth 1 (only the initial state remains).  The initial state is never
435    /// popped; this preserves the stack invariant (`depth ≥ 1`).
436    ///
437    /// Callers that receive `false` should treat it as an unmatched `Q` operator
438    /// and continue rendering with the current state unchanged.
439    pub fn restore(&mut self) -> bool {
440        if self.stack.len() <= 1 {
441            // Invariant: never pop the last (initial) state.
442            return false;
443        }
444        drop(self.stack.pop());
445        true
446    }
447
448    /// Current nesting depth (`1` = only the initial state, no saves in progress).
449    #[must_use]
450    pub const fn depth(&self) -> usize {
451        self.stack.len()
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn default_matrix_is_identity() {
461        let s = GraphicsState::new(100, 100, false);
462        // These values are set by assignment from a literal, so exact equality is correct.
463        assert!(
464            s.matrix
465                .iter()
466                .zip([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])
467                .all(|(a, b)| (a - b).abs() < f64::EPSILON)
468        );
469    }
470
471    #[test]
472    fn default_alpha_is_one() {
473        let s = GraphicsState::new(100, 100, false);
474        assert!((s.stroke_alpha - 1.0).abs() < f64::EPSILON);
475        assert!((s.fill_alpha - 1.0).abs() < f64::EPSILON);
476    }
477
478    #[test]
479    fn default_overprint_mask() {
480        let s = GraphicsState::new(100, 100, false);
481        assert_eq!(s.overprint_mask, 0xFFFF_FFFF);
482    }
483
484    #[test]
485    fn default_transfer_is_identity() {
486        let s = GraphicsState::new(100, 100, false);
487        for i in 0u8..=255 {
488            assert_eq!(s.rgb_transfer[0].apply(i), i);
489            assert_eq!(s.gray_transfer.apply(i), i);
490        }
491    }
492
493    #[test]
494    fn clip_has_sub_pixel_inset() {
495        let s = GraphicsState::new(200, 100, false);
496        // x_max < 200.0, y_max < 100.0
497        assert!(s.clip.x_max < 200.0);
498        assert!(s.clip.y_max < 100.0);
499    }
500
501    #[test]
502    fn save_restore_roundtrip() {
503        let initial = GraphicsState::new(100, 100, false);
504        let mut stack = StateStack::new(initial);
505        assert_eq!(stack.depth(), 1);
506
507        stack.save();
508        assert_eq!(stack.depth(), 2);
509        stack.current_mut().line_width = 5.0;
510
511        assert!(stack.restore());
512        assert_eq!(stack.depth(), 1);
513        assert!((stack.current().line_width - 1.0).abs() < f64::EPSILON); // restored to default
514    }
515
516    #[test]
517    fn restore_at_bottom_returns_false() {
518        let initial = GraphicsState::new(100, 100, false);
519        let mut stack = StateStack::new(initial);
520        assert!(!stack.restore());
521        assert_eq!(stack.depth(), 1);
522    }
523
524    #[test]
525    fn set_transfer_derives_cmyk() {
526        let mut s = GraphicsState::new(100, 100, false);
527        // Inversion LUT: i → 255-i
528        let inv: [u8; 256] = std::array::from_fn(|i| u8::try_from(255 - i).expect("i < 256"));
529        s.set_transfer(&inv, &inv, &inv, &inv);
530        // CMYK C = 255 - R[255-i] (before overwrite of R); with identity R:
531        // R[255-i] = 255-i, so C[i] = 255-(255-i) = i → identity
532        for i in 0u8..=255 {
533            assert_eq!(s.cmyk_transfer[0].apply(i), i, "cmyk C[{i}]");
534        }
535    }
536
537    /// In debug builds, constructing a `GraphicsState` with zero width must panic.
538    #[test]
539    #[cfg(debug_assertions)]
540    #[should_panic(expected = "width must be > 0")]
541    fn new_panics_on_zero_width() {
542        let _ = GraphicsState::new(0, 100, false);
543    }
544
545    /// In debug builds, constructing a `GraphicsState` with zero height must panic.
546    #[test]
547    #[cfg(debug_assertions)]
548    #[should_panic(expected = "height must be > 0")]
549    fn new_panics_on_zero_height() {
550        let _ = GraphicsState::new(100, 0, false);
551    }
552
553    /// Verify that multiple unmatched restores never pop below depth 1.
554    #[test]
555    fn restore_never_pops_below_one() {
556        let initial = GraphicsState::new(100, 100, false);
557        let mut stack = StateStack::new(initial);
558        stack.save();
559        stack.save();
560        assert!(stack.restore());
561        assert!(stack.restore());
562        // At bottom now — further restores must return false without changing depth.
563        assert!(!stack.restore());
564        assert!(!stack.restore());
565        assert_eq!(stack.depth(), 1);
566    }
567}