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}