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}