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/// A single recorded drawing operation.
125#[derive(Clone)]
126pub enum DisplayElement {
127    /// Fill a path.
128    Fill { path: PsPath, params: FillParams },
129    /// Stroke a path.
130    Stroke { path: PsPath, params: StrokeParams },
131    /// Intersect the clip region with a path.
132    Clip { path: PsPath, params: ClipParams },
133    /// Reset clipping to the full page.
134    InitClip,
135    /// Draw an image (raw sample data in native color space).
136    Image {
137        sample_data: Arc<Vec<u8>>,
138        params: ImageParams,
139    },
140    /// Erase the page (fill with white).
141    ErasePage,
142    /// Axial (linear) gradient shading.
143    AxialShading { params: AxialShadingParams },
144    /// Radial gradient shading.
145    RadialShading { params: RadialShadingParams },
146    /// Gouraud-shaded triangle mesh.
147    MeshShading { params: MeshShadingParams },
148    /// Coons/tensor-product patch mesh.
149    PatchShading { params: PatchShadingParams },
150    /// Tiled pattern fill.
151    PatternFill { params: PatternFillParams },
152    /// Text element from show operators (used by PDF device, ignored by rasterizer).
153    Text { params: TextParams },
154    /// Transparency group: render children offscreen, composite with blend mode + alpha.
155    Group {
156        elements: DisplayList,
157        params: GroupParams,
158    },
159    /// PDF Optional Content Group (layer). Children are rendered only when
160    /// the layer is visible. Visibility defaults to `default_visible` and
161    /// can be overridden at render time via a set of hidden layer IDs.
162    OcgGroup {
163        elements: DisplayList,
164        /// PDF object number identifying the OCG (or OCMD).
165        ocg_id: u32,
166        /// Whether this layer is visible in the default configuration.
167        default_visible: bool,
168    },
169    /// Soft-masked content: render mask form to grayscale, multiply with content alpha.
170    SoftMasked {
171        mask: DisplayList,
172        content: DisplayList,
173        params: SoftMaskParams,
174        /// Render-time cache of the rasterized mask. `None` means "not
175        /// yet rasterized". `Some(None)` means "rasterized and produced
176        /// no visible mask" — memoized so subsequent bands skip the
177        /// rasterization work. `Some(Some(raster))` is the populated
178        /// raster; the renderer compares `raster.scale_x/scale_y`
179        /// against the current render scale and re-rasterizes if they
180        /// differ.
181        ///
182        /// Wrapped in `Arc<Mutex<...>>` so cloned display lists (e.g. by
183        /// the egui viewer or the WASM viewport during zoom) share the
184        /// same cache cell, and so the cache can be replaced when the
185        /// scale changes.
186        mask_cache: Arc<Mutex<Option<Option<MaskRaster>>>>,
187    },
188}
189
190/// An ordered list of drawing operations for a single page.
191#[derive(Clone)]
192pub struct DisplayList {
193    elements: Vec<DisplayElement>,
194    /// Color space of the page-level transparency group, when one is declared.
195    /// Per PDF spec §11.6.7 the page group's color space is the one in which
196    /// any contained transparency compositing must be performed; renderers
197    /// use this to decide whether to track CMYK alongside sRGB.
198    page_group_color_space: GroupColorSpace,
199}
200
201impl DisplayList {
202    /// Create an empty display list.
203    pub fn new() -> Self {
204        Self {
205            elements: Vec::new(),
206            page_group_color_space: GroupColorSpace::Inherited,
207        }
208    }
209
210    /// Returns the page-level transparency group color space.
211    pub fn page_group_color_space(&self) -> GroupColorSpace {
212        self.page_group_color_space
213    }
214
215    /// Set the page-level transparency group color space (called by the PDF
216    /// reader when the page dictionary declares a `/Group /CS`).
217    pub fn set_page_group_color_space(&mut self, cs: GroupColorSpace) {
218        self.page_group_color_space = cs;
219    }
220
221    /// Append a drawing operation.
222    pub fn push(&mut self, element: DisplayElement) {
223        self.elements.push(element);
224    }
225
226    /// Access recorded elements.
227    pub fn elements(&self) -> &[DisplayElement] {
228        &self.elements
229    }
230
231    /// Discard all recorded operations.
232    pub fn clear(&mut self) {
233        self.elements.clear();
234    }
235
236    /// Returns true if the display list has no elements.
237    pub fn is_empty(&self) -> bool {
238        self.elements.is_empty()
239    }
240
241    /// Returns the number of elements.
242    pub fn len(&self) -> usize {
243        self.elements.len()
244    }
245
246    /// Returns a slice of elements starting from the given index.
247    pub fn elements_from(&self, start: usize) -> &[DisplayElement] {
248        &self.elements[start..]
249    }
250
251    /// Drain elements from `start..` into a new DisplayList, truncating self.
252    pub fn split_off(&mut self, start: usize) -> DisplayList {
253        let drained: Vec<DisplayElement> = self.elements.drain(start..).collect();
254        DisplayList {
255            elements: drained,
256            page_group_color_space: GroupColorSpace::Inherited,
257        }
258    }
259
260    /// Consume the display list and return the elements.
261    pub fn into_elements(self) -> Vec<DisplayElement> {
262        self.elements
263    }
264}
265
266impl Default for DisplayList {
267    fn default() -> Self {
268        Self::new()
269    }
270}