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}