Skip to main content

stet_graphics/
display_list.rs

1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Display list — records drawing operations for deferred replay to a device.
6
7use std::sync::{Arc, Mutex};
8
9use crate::device::{
10    AxialShadingParams, ClipParams, FillParams, ImageParams, MeshShadingParams, PatchShadingParams,
11    PatternFillParams, RadialShadingParams, StrokeParams, TextParams,
12};
13use stet_fonts::geometry::PsPath;
14
15/// A pre-rasterized soft mask, cached on the display-list `SoftMasked`
16/// element so the renderer can build it once at gs-time CTM and sample it
17/// per content pixel without re-rasterizing for every band.
18///
19/// The renderer holds the mask raster in its own device-space pixel
20/// coordinate system (anchored at `(origin_x, origin_y)`) rather than the
21/// `SoftMasked.params.bbox` viewport, because the mask form's internal
22/// `cm` operators may translate the actual paint elements outside the
23/// form's `/BBox` after the gs-time CTM is applied. Sampling per content
24/// pixel by device coordinates decouples the mask and content coordinate
25/// systems entirely.
26#[derive(Clone, Debug)]
27pub struct MaskRaster {
28    /// Single-channel mask values (luminosity or alpha), row-major.
29    pub data: Vec<u8>,
30    /// Width of the raster in pixels.
31    pub width: u32,
32    /// Height of the raster in pixels.
33    pub height: u32,
34    /// Top-left corner of the raster in device-space pixels at `scale`.
35    pub origin_x: i32,
36    /// Top-left corner of the raster in device-space pixels at `scale`.
37    pub origin_y: i32,
38    /// Horizontal scale at which the raster was built. The CLI egui
39    /// viewer and the WASM viewport both re-render the same captured
40    /// display list at varying zoom scales, so the cache must invalidate
41    /// when this changes.
42    pub scale_x: f32,
43    /// Vertical scale at which the raster was built.
44    pub scale_y: f32,
45}
46
47/// Subtype for soft mask extraction.
48#[derive(Clone, Debug, PartialEq)]
49pub enum SoftMaskSubtype {
50    /// Use the alpha channel of the rendered mask directly.
51    Alpha,
52    /// Convert rendered mask to luminosity (grayscale).
53    Luminosity,
54}
55
56/// Parameters for a soft mask compositing operation.
57#[derive(Clone)]
58pub struct SoftMaskParams {
59    /// How to extract the mask from the rendered form.
60    pub subtype: SoftMaskSubtype,
61    /// Device-space bounding box [x_min, y_min, x_max, y_max].
62    pub bbox: [f64; 4],
63    /// Backdrop color for luminosity masks (RGB, 0.0–1.0). None = black.
64    pub backdrop_color: Option<[f64; 3]>,
65    /// Whether the mask values should be inverted (from /TR `{1 exch sub}`).
66    pub transfer_invert: bool,
67    /// Whether the mask form contained nested soft mask scopes (gs-set SMask).
68    /// When true, the renderer composites semi-transparent pixels onto the
69    /// backdrop before extracting luminosity.
70    pub has_nested_mask_scope: bool,
71    /// Bounding box of the parent gstate's clip path at the moment the
72    /// SoftMasked element was emitted (in device space).
73    ///
74    /// Used by the renderer as a hard upper bound on the cached mask
75    /// raster size: pixels outside the parent clip can't affect the
76    /// final image, so the raster never needs to extend beyond it.
77    /// Without this cap, a soft mask whose form contains an unbounded
78    /// shading (no `/BBox`) inside a sentinel-sized internal clip would
79    /// blow past the renderer's mask-raster size limit and rasterize
80    /// to nothing, making the entire SoftMasked element invisible.
81    ///
82    /// `None` means the parent had no active clip path — the renderer
83    /// then bounds the raster only by the mask's actual paint bounds.
84    pub parent_clip_bbox: Option<[f64; 4]>,
85}
86
87/// Color space declared by a transparency group's `/CS` entry. Per PDF spec
88/// §11.6.7, this is the color space in which the group's compositing
89/// computations are performed; renderers that need spec-correct blend mode
90/// math (especially for the inversion-sensitive separable modes and the HSL
91/// non-separable modes) must operate in this space rather than the device's
92/// display space.
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum GroupColorSpace {
95    /// No `/CS` entry — inherits from the enclosing group / page group.
96    Inherited,
97    /// `/DeviceGray` or `/CalGray` or `/ICCBased` with N=1.
98    DeviceGray,
99    /// `/DeviceRGB` or `/CalRGB` or `/ICCBased` with N=3.
100    DeviceRGB,
101    /// `/DeviceCMYK` or `/ICCBased` with N=4.
102    DeviceCMYK,
103}
104
105/// Parameters for a transparency group compositing operation.
106#[derive(Clone)]
107pub struct GroupParams {
108    /// Device-space bounding box [x_min, y_min, x_max, y_max].
109    pub bbox: [f64; 4],
110    /// Whether the group is isolated (renders against transparent backdrop).
111    pub isolated: bool,
112    /// Whether the group uses knockout semantics (elements composite against
113    /// the initial backdrop, not against accumulated siblings).
114    pub knockout: bool,
115    /// Blend mode for compositing the group result onto the parent.
116    pub blend_mode: u8,
117    /// Opacity for compositing the group result (0.0–1.0).
118    pub alpha: f64,
119    /// Group's transparency color space (`/CS` entry on the `/Group` dict).
120    /// Inherited from the enclosing group when not explicitly declared.
121    pub color_space: GroupColorSpace,
122}
123
124/// Visibility predicate for an [`DisplayElement::OcgGroup`].
125///
126/// Three forms exist in PDF:
127///
128/// - `Single` — a `/OC BDC` block whose property is a direct OCG ref,
129///   or a single-OCG OCMD. Most common case.
130/// - `Membership` — an OCMD with a `/P` policy (AllOn / AnyOn /
131///   AllOff / AnyOff) over multiple OCGs. PDF default policy is
132///   `AnyOn`.
133/// - `Expression` — an OCMD with a `/VE` boolean expression
134///   (PDF 1.6+). Most expressive form.
135///
136/// The renderer evaluates the predicate against the active
137/// `LayerSet` (in `stet-pdf-reader`); each variant's
138/// `default_visible` is the fallback when the LayerSet has no
139/// opinion.
140#[derive(Clone, Debug)]
141pub enum OcgVisibility {
142    /// Visibility tied to a single OCG.
143    Single { ocg_id: u32, default_visible: bool },
144    /// OCMD with a `/P` membership policy over a set of OCGs.
145    Membership {
146        ocg_ids: Vec<u32>,
147        policy: MembershipPolicy,
148        default_visible: bool,
149    },
150    /// OCMD with a `/VE` visibility expression.
151    Expression {
152        expr: VisibilityExpr,
153        default_visible: bool,
154    },
155}
156
157/// `/P` policy on an OCMD.
158#[derive(Clone, Copy, Debug, PartialEq, Eq)]
159pub enum MembershipPolicy {
160    /// `/AllOn` — visible iff every OCG is on.
161    AllOn,
162    /// `/AnyOn` — visible iff at least one OCG is on. PDF default.
163    AnyOn,
164    /// `/AllOff` — visible iff every OCG is off.
165    AllOff,
166    /// `/AnyOff` — visible iff at least one OCG is off.
167    AnyOff,
168}
169
170/// Boolean visibility expression from an OCMD `/VE` array.
171///
172/// The parser always emits the canonical form (operands as
173/// `Vec<VisibilityExpr>`); leaves are layer references.
174#[derive(Clone, Debug)]
175pub enum VisibilityExpr {
176    /// Conjunction — visible iff every operand is visible.
177    And(Vec<VisibilityExpr>),
178    /// Disjunction — visible iff any operand is visible.
179    Or(Vec<VisibilityExpr>),
180    /// Negation — exactly one operand.
181    Not(Box<VisibilityExpr>),
182    /// Leaf: refer to a single OCG by object number.
183    Layer(u32),
184}
185
186impl OcgVisibility {
187    /// Convenience constructor for the most common case.
188    pub fn single(ocg_id: u32, default_visible: bool) -> Self {
189        OcgVisibility::Single {
190            ocg_id,
191            default_visible,
192        }
193    }
194
195    /// The fallback visibility used when no `LayerSet` has an
196    /// opinion. Renderers that do not consult a LayerSet read this.
197    pub fn default_visible(&self) -> bool {
198        match self {
199            OcgVisibility::Single {
200                default_visible, ..
201            }
202            | OcgVisibility::Membership {
203                default_visible, ..
204            }
205            | OcgVisibility::Expression {
206                default_visible, ..
207            } => *default_visible,
208        }
209    }
210}
211
212/// A single recorded drawing operation.
213///
214/// Marked `#[non_exhaustive]` so additional element kinds can land
215/// without breaking third-party renderers; consumers must include a
216/// wildcard arm in their `match` expressions. See
217/// `docs/DISPLAY-LIST.md` ("Stability") for the policy.
218#[derive(Clone)]
219#[non_exhaustive]
220pub enum DisplayElement {
221    /// Fill a path.
222    Fill { path: PsPath, params: FillParams },
223    /// Stroke a path.
224    Stroke { path: PsPath, params: StrokeParams },
225    /// Intersect the clip region with a path.
226    Clip { path: PsPath, params: ClipParams },
227    /// Reset clipping to the full page.
228    InitClip,
229    /// Draw an image (raw sample data in native color space).
230    Image {
231        sample_data: Arc<Vec<u8>>,
232        params: ImageParams,
233    },
234    /// Erase the page (fill with white).
235    ErasePage,
236    /// Axial (linear) gradient shading.
237    AxialShading { params: AxialShadingParams },
238    /// Radial gradient shading.
239    RadialShading { params: RadialShadingParams },
240    /// Gouraud-shaded triangle mesh.
241    MeshShading { params: MeshShadingParams },
242    /// Coons/tensor-product patch mesh.
243    PatchShading { params: PatchShadingParams },
244    /// Tiled pattern fill.
245    PatternFill { params: PatternFillParams },
246    /// Text element from show operators (used by PDF device, ignored by rasterizer).
247    Text { params: TextParams },
248    /// Transparency group: render children offscreen, composite with blend mode + alpha.
249    Group {
250        elements: DisplayList,
251        params: GroupParams,
252    },
253    /// PDF Optional Content Group (layer). Children are rendered only
254    /// when [`OcgVisibility`] evaluates to `true` under the active
255    /// `LayerSet` (consult `stet-pdf-reader`'s `LayerSet::evaluate`).
256    /// `default_visible` on each variant is the fallback used when the
257    /// renderer has no `LayerSet` opinion for the relevant OCGs.
258    OcgGroup {
259        elements: DisplayList,
260        /// Visibility predicate: a single OCG, an OCMD membership
261        /// policy, or a /VE expression.
262        visibility: OcgVisibility,
263    },
264    /// Soft-masked content: render mask form to grayscale, multiply with content alpha.
265    SoftMasked {
266        mask: DisplayList,
267        content: DisplayList,
268        params: SoftMaskParams,
269        /// Render-time cache of the rasterized mask. `None` means "not
270        /// yet rasterized". `Some(None)` means "rasterized and produced
271        /// no visible mask" — memoized so subsequent bands skip the
272        /// rasterization work. `Some(Some(raster))` is the populated
273        /// raster; the renderer compares `raster.scale_x/scale_y`
274        /// against the current render scale and re-rasterizes if they
275        /// differ.
276        ///
277        /// Wrapped in `Arc<Mutex<...>>` so cloned display lists (e.g. by
278        /// the egui viewer or the WASM viewport during zoom) share the
279        /// same cache cell, and so the cache can be replaced when the
280        /// scale changes.
281        mask_cache: Arc<Mutex<Option<Option<MaskRaster>>>>,
282    },
283}
284
285/// An ordered list of drawing operations for a single page.
286#[derive(Clone)]
287pub struct DisplayList {
288    elements: Vec<DisplayElement>,
289    /// Color space of the page-level transparency group, when one is declared.
290    /// Per PDF spec §11.6.7 the page group's color space is the one in which
291    /// any contained transparency compositing must be performed; renderers
292    /// use this to decide whether to track CMYK alongside sRGB.
293    page_group_color_space: GroupColorSpace,
294}
295
296impl DisplayList {
297    /// Create an empty display list.
298    pub fn new() -> Self {
299        Self {
300            elements: Vec::new(),
301            page_group_color_space: GroupColorSpace::Inherited,
302        }
303    }
304
305    /// Returns the page-level transparency group color space.
306    pub fn page_group_color_space(&self) -> GroupColorSpace {
307        self.page_group_color_space
308    }
309
310    /// Set the page-level transparency group color space (called by the PDF
311    /// reader when the page dictionary declares a `/Group /CS`).
312    pub fn set_page_group_color_space(&mut self, cs: GroupColorSpace) {
313        self.page_group_color_space = cs;
314    }
315
316    /// Append a drawing operation.
317    pub fn push(&mut self, element: DisplayElement) {
318        self.elements.push(element);
319    }
320
321    /// Access recorded elements.
322    pub fn elements(&self) -> &[DisplayElement] {
323        &self.elements
324    }
325
326    /// Discard all recorded operations.
327    pub fn clear(&mut self) {
328        self.elements.clear();
329    }
330
331    /// Returns true if the display list has no elements.
332    pub fn is_empty(&self) -> bool {
333        self.elements.is_empty()
334    }
335
336    /// Returns the number of elements.
337    pub fn len(&self) -> usize {
338        self.elements.len()
339    }
340
341    /// Returns a slice of elements starting from the given index.
342    pub fn elements_from(&self, start: usize) -> &[DisplayElement] {
343        &self.elements[start..]
344    }
345
346    /// Drain elements from `start..` into a new DisplayList, truncating self.
347    pub fn split_off(&mut self, start: usize) -> DisplayList {
348        let drained: Vec<DisplayElement> = self.elements.drain(start..).collect();
349        DisplayList {
350            elements: drained,
351            page_group_color_space: GroupColorSpace::Inherited,
352        }
353    }
354
355    /// Consume the display list and return the elements.
356    pub fn into_elements(self) -> Vec<DisplayElement> {
357        self.elements
358    }
359}
360
361impl Default for DisplayList {
362    fn default() -> Self {
363        Self::new()
364    }
365}