Skip to main content

stet_core/
device.rs

1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Output device trait — abstraction boundary for rendering backends.
6
7use crate::display_list::{DisplayElement, DisplayList};
8use crate::graphics_state::PsPath;
9
10// ── Re-exports from stet-graphics ───────────────────────────────────────────
11// These types used to be defined here. Re-export for backward compatibility.
12pub use stet_graphics::device::{
13    AxialShadingParams, BgUcrState, CMYK_ALL, CMYK_C, CMYK_K, CMYK_M, CMYK_Y, ClipParams,
14    ColorStop, FillParams, HalftoneScreen, HalftoneState, ImageColorSpace, ImageParams,
15    MeshShadingParams, PatchShadingParams, PatternFillParams, RadialShadingParams,
16    ShadingColorSpace, ShadingPatch, ShadingTriangle, ShadingVertex, SimpleColorSpace, SpotColor,
17    SpotColorSpace, StrokeParams, TextParams, TintLookupTable, TransferState, TransferTable,
18    cmyk_channel_for_name,
19};
20pub use stet_graphics::device::{PageSink, PageSinkFactory};
21
22/// Trait for raster rendering devices.
23///
24/// Operators never see the concrete implementation — they call trait methods.
25/// This enables backend swaps (tiny-skia, cairo, etc.) without changing operator code.
26pub trait OutputDevice {
27    /// Fill a path with the given color.
28    fn fill_path(&mut self, path: &PsPath, params: &FillParams);
29
30    /// Stroke a path with the given parameters.
31    fn stroke_path(&mut self, path: &PsPath, params: &StrokeParams);
32
33    /// Intersect the current clip region with the given path.
34    fn clip_path(&mut self, path: &PsPath, params: &ClipParams);
35
36    /// Reset clipping to the full page.
37    fn init_clip(&mut self);
38
39    /// Erase the page (fill with white).
40    fn erase_page(&mut self);
41
42    /// Output the current page (e.g., save PNG) and return Ok/Err.
43    fn show_page(&mut self, output_path: &str) -> Result<(), String>;
44
45    /// Draw an image from raw sample data.
46    fn draw_image(&mut self, sample_data: &[u8], params: &ImageParams);
47
48    /// Paint an axial (linear) gradient shading.
49    fn paint_axial_shading(&mut self, _params: &AxialShadingParams) {}
50
51    /// Paint a radial gradient shading.
52    fn paint_radial_shading(&mut self, _params: &RadialShadingParams) {}
53
54    /// Paint a Gouraud-shaded triangle mesh.
55    fn paint_mesh_shading(&mut self, _params: &MeshShadingParams) {}
56
57    /// Paint a Coons/tensor-product patch mesh.
58    fn paint_patch_shading(&mut self, _params: &PatchShadingParams) {}
59
60    /// Paint a tiled pattern fill.
61    fn paint_pattern_fill(&mut self, _params: &PatternFillParams) {}
62
63    /// Set the trim box for the next page (PDF points, lower-left origin).
64    /// Only meaningful for PDF output; other devices ignore this.
65    fn set_trim_box(&mut self, _llx: f64, _lly: f64, _urx: f64, _ury: f64) {}
66
67    /// Whether each page becomes its own output file.
68    ///
69    /// True for raster devices, which write one image per `showpage`; false
70    /// for devices such as PDF that accumulate every page into a single file
71    /// and only write it at end of job. The distinction decides whether an
72    /// explicit `--output` path without a `%d` page-number token can serve a
73    /// multi-page job: a single-file device is happy with one name, a
74    /// page-per-file device would overwrite its own earlier pages.
75    fn writes_file_per_page(&self) -> bool {
76        true
77    }
78
79    /// Page dimensions in device pixels.
80    fn page_size(&self) -> (u32, u32);
81
82    /// Replay a display list and write output in one step.
83    fn replay_and_show(&mut self, list: DisplayList, output_path: &str) -> Result<(), String> {
84        for element in list.elements() {
85            match element {
86                DisplayElement::Fill { path, params } => self.fill_path(path, params),
87                DisplayElement::Stroke { path, params } => self.stroke_path(path, params),
88                DisplayElement::Clip { path, params } => self.clip_path(path, params),
89                DisplayElement::InitClip => self.init_clip(),
90                DisplayElement::Image {
91                    sample_data,
92                    params,
93                } => self.draw_image(sample_data, params),
94                DisplayElement::ErasePage => self.erase_page(),
95                DisplayElement::AxialShading { params } => self.paint_axial_shading(params),
96                DisplayElement::RadialShading { params } => self.paint_radial_shading(params),
97                DisplayElement::MeshShading { params } => self.paint_mesh_shading(params),
98                DisplayElement::PatchShading { params } => self.paint_patch_shading(params),
99                DisplayElement::PatternFill { params } => self.paint_pattern_fill(params),
100                DisplayElement::Text { .. } => {} // PDF-only, ignored by rasterizer
101                DisplayElement::Group { .. } | DisplayElement::SoftMasked { .. } => {
102                    // Groups/SoftMasked are handled by the banded renderer (SkiaDevice).
103                }
104                DisplayElement::OcgGroup {
105                    elements,
106                    visibility,
107                } => {
108                    // Clip ops always apply so subsequent top-level elements
109                    // inherit the right clip region, even when the layer is
110                    // hidden; paint ops are gated on visibility.
111                    let visible = visibility.default_visible();
112                    for elem in elements.elements() {
113                        match elem {
114                            DisplayElement::Clip { path, params } => self.clip_path(path, params),
115                            DisplayElement::InitClip => self.init_clip(),
116                            _ if !visible => {}
117                            DisplayElement::Fill { path, params } => self.fill_path(path, params),
118                            DisplayElement::Stroke { path, params } => {
119                                self.stroke_path(path, params)
120                            }
121                            DisplayElement::Image {
122                                sample_data,
123                                params,
124                            } => self.draw_image(sample_data, params),
125                            DisplayElement::ErasePage => self.erase_page(),
126                            DisplayElement::AxialShading { params } => {
127                                self.paint_axial_shading(params)
128                            }
129                            DisplayElement::RadialShading { params } => {
130                                self.paint_radial_shading(params)
131                            }
132                            DisplayElement::MeshShading { params } => {
133                                self.paint_mesh_shading(params)
134                            }
135                            DisplayElement::PatchShading { params } => {
136                                self.paint_patch_shading(params)
137                            }
138                            DisplayElement::PatternFill { params } => {
139                                self.paint_pattern_fill(params)
140                            }
141                            _ => {}
142                        }
143                    }
144                }
145                _ => {}
146            }
147        }
148        self.show_page(output_path)
149    }
150
151    /// Wait for any pending background render to complete.
152    fn finish(&mut self) -> Result<(), String> {
153        Ok(())
154    }
155
156    /// Called after interpretation finishes, with access to the interpreter context.
157    fn finish_with_context(&mut self, _ctx: &crate::context::Context) -> Result<(), String> {
158        self.finish()
159    }
160
161    /// Downcast to a concrete type. Override in implementations that need
162    /// to be accessed after rendering (e.g., PdfDevice for in-memory output).
163    fn as_any(&self) -> &dyn std::any::Any {
164        // Default: return a unit reference (downcasts will fail gracefully)
165        &()
166    }
167}
168
169/// A null rendering device that discards all output.
170pub struct NullDevice {
171    width: u32,
172    height: u32,
173}
174
175impl NullDevice {
176    pub fn new(width: u32, height: u32) -> Self {
177        Self { width, height }
178    }
179}
180
181impl OutputDevice for NullDevice {
182    fn fill_path(&mut self, _path: &PsPath, _params: &FillParams) {}
183    fn stroke_path(&mut self, _path: &PsPath, _params: &StrokeParams) {}
184    fn clip_path(&mut self, _path: &PsPath, _params: &ClipParams) {}
185    fn init_clip(&mut self) {}
186    fn erase_page(&mut self) {}
187    fn show_page(&mut self, _output_path: &str) -> Result<(), String> {
188        Ok(())
189    }
190    fn draw_image(&mut self, _sample_data: &[u8], _params: &ImageParams) {}
191    fn page_size(&self) -> (u32, u32) {
192        (self.width, self.height)
193    }
194}
195
196/// Replay a display list to any raster device.
197pub fn replay_to_device(list: &DisplayList, device: &mut dyn OutputDevice) {
198    for element in list.elements() {
199        match element {
200            DisplayElement::Fill { path, params } => {
201                device.fill_path(path, params);
202            }
203            DisplayElement::Stroke { path, params } => {
204                device.stroke_path(path, params);
205            }
206            DisplayElement::Clip { path, params } => {
207                device.clip_path(path, params);
208            }
209            DisplayElement::InitClip => {
210                device.init_clip();
211            }
212            DisplayElement::Image {
213                sample_data,
214                params,
215            } => {
216                device.draw_image(sample_data, params);
217            }
218            DisplayElement::ErasePage => {
219                device.erase_page();
220            }
221            DisplayElement::AxialShading { params } => {
222                device.paint_axial_shading(params);
223            }
224            DisplayElement::RadialShading { params } => {
225                device.paint_radial_shading(params);
226            }
227            DisplayElement::MeshShading { params } => {
228                device.paint_mesh_shading(params);
229            }
230            DisplayElement::PatchShading { params } => {
231                device.paint_patch_shading(params);
232            }
233            DisplayElement::PatternFill { params } => {
234                device.paint_pattern_fill(params);
235            }
236            DisplayElement::Text { .. } => {}
237            DisplayElement::Group { elements, .. } => {
238                replay_to_device(elements, device);
239            }
240            DisplayElement::SoftMasked { content, .. } => {
241                replay_to_device(content, device);
242            }
243            DisplayElement::OcgGroup {
244                elements,
245                visibility,
246            } => {
247                if visibility.default_visible() {
248                    replay_to_device(elements, device);
249                } else {
250                    // Still replay Clip/InitClip so they affect subsequent
251                    // top-level elements (see OcgGroup render path for the
252                    // rationale).
253                    for elem in elements.elements() {
254                        match elem {
255                            DisplayElement::Clip { path, params } => {
256                                device.clip_path(path, params);
257                            }
258                            DisplayElement::InitClip => {
259                                device.init_clip();
260                            }
261                            _ => {}
262                        }
263                    }
264                }
265            }
266            _ => {}
267        }
268    }
269}