Skip to main content

stet_graphics/
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 parameter types — pure data structures for rendering operations.
6
7use crate::color::{DashPattern, DeviceColor, FillRule, LineCap, LineJoin};
8use crate::display_list::DisplayList;
9use crate::icc::ProfileHash;
10use std::sync::Arc;
11use stet_fonts::geometry::{Matrix, PsPath};
12
13/// Pre-sampled transfer function (256 samples, domain `[0,1]` → range `[0,1]`).
14/// Arc for cheap clone across display list elements.
15pub type TransferTable = Arc<Vec<f64>>;
16
17/// Transfer function state captured at paint time.
18#[derive(Clone, Debug, Default)]
19pub struct TransferState {
20    /// Single-component transfer (from settransfer). None = identity.
21    pub gray: Option<TransferTable>,
22    /// Per-component color transfer \[R, G, B, Gray\] (from setcolortransfer).
23    /// When set, overrides `gray`.
24    pub color: Option<[Option<TransferTable>; 4]>,
25}
26
27impl TransferState {
28    /// Returns true if any non-identity transfer function is set.
29    pub fn has_functions(&self) -> bool {
30        if self.gray.is_some() {
31            return true;
32        }
33        if let Some(ref color) = self.color {
34            return color.iter().any(|t| t.is_some());
35        }
36        false
37    }
38}
39
40/// A pre-computed halftone screen for PDF output.
41#[derive(Clone, Debug)]
42pub struct HalftoneScreen {
43    pub frequency: f64,
44    pub angle: f64,
45    /// Spot function as PDF Type 4 calculator bytes (e.g., b"{ dup mul exch dup mul add 1 exch sub }").
46    /// None if conversion failed (falls back to sampled_2d).
47    pub type4_tokens: Option<Arc<Vec<u8>>>,
48    /// Spot function sampled on a 64×64 grid (4096 f64 values, domain `[-1,1]²`, range `[0,1]`).
49    /// Used when Type 4 decompilation fails.
50    pub sampled_2d: Option<Arc<Vec<f64>>>,
51}
52
53/// Pre-sampled black generation / undercolor removal state for PDF output.
54#[derive(Clone, Debug, Default)]
55pub struct BgUcrState {
56    /// Black generation function (256 samples, domain `[0,1]` → range `[0,1]`).
57    pub bg: Option<Arc<Vec<f64>>>,
58    /// Undercolor removal function (256 samples, domain `[0,1]` → range `[-1,1]`).
59    pub ucr: Option<Arc<Vec<f64>>>,
60}
61
62/// Pre-computed halftone state captured at paint time.
63#[derive(Clone, Debug, Default)]
64pub struct HalftoneState {
65    /// Single-component halftone (from setscreen). None = default (suppress).
66    pub gray: Option<Arc<HalftoneScreen>>,
67    /// Per-component \[R, G, B, Gray\] (from setcolorscreen). Emits Type 5 composite.
68    pub color: Option<[Option<Arc<HalftoneScreen>>; 4]>,
69}
70
71/// Native Separation/DeviceN color info for PDF output.
72#[derive(Clone, Debug)]
73pub struct SpotColor {
74    /// Tint values from the most recent setcolor (1 for Separation, N for DeviceN).
75    pub tint_values: Vec<f64>,
76    /// Color space definition for this spot color.
77    pub color_space: SpotColorSpace,
78}
79
80/// Separation or DeviceN color space with pre-sampled tint function.
81#[derive(Clone, Debug)]
82pub enum SpotColorSpace {
83    Separation {
84        name: Vec<u8>,
85        alt: SimpleColorSpace,
86        tint_table: Arc<TintLookupTable>,
87    },
88    DeviceN {
89        names: Vec<Vec<u8>>,
90        alt: SimpleColorSpace,
91        tint_table: Arc<TintLookupTable>,
92    },
93}
94
95/// Simple device color space for alt-space references.
96#[derive(Clone, Debug, PartialEq, Eq, Hash)]
97pub enum SimpleColorSpace {
98    DeviceGray,
99    DeviceRGB,
100    DeviceCMYK,
101}
102
103/// Bitmask of CMYK channels painted by an overprint operation.
104/// Bits: 0=Cyan, 1=Magenta, 2=Yellow, 3=Black.
105pub const CMYK_C: u8 = 1 << 0;
106pub const CMYK_M: u8 = 1 << 1;
107pub const CMYK_Y: u8 = 1 << 2;
108pub const CMYK_K: u8 = 1 << 3;
109pub const CMYK_ALL: u8 = CMYK_C | CMYK_M | CMYK_Y | CMYK_K;
110
111/// Map a CMYK process color name to its channel bit.
112pub fn cmyk_channel_for_name(name: &[u8]) -> u8 {
113    match name {
114        b"Cyan" => CMYK_C,
115        b"Magenta" => CMYK_M,
116        b"Yellow" => CMYK_Y,
117        b"Black" => CMYK_K,
118        b"All" => CMYK_ALL,
119        b"None" => 0,
120        _ => 0,
121    }
122}
123
124/// Parameters for filling a path.
125#[derive(Clone, Debug)]
126pub struct FillParams {
127    pub color: DeviceColor,
128    pub fill_rule: FillRule,
129    pub ctm: Matrix,
130    /// True when this fill is a text glyph from a show operator.
131    /// PDF device skips these (uses Text elements instead).
132    pub is_text_glyph: bool,
133    /// Overprint flag from graphics state (used by PDF output).
134    pub overprint: bool,
135    /// Overprint mode (0 or 1). With OPM 1 + DeviceCMYK, only non-zero channels are painted.
136    pub overprint_mode: i32,
137    /// True when /OPM was set together with /op or /OP in the same ExtGState
138    /// dict that configured this fill. Enables strict OPM-1 "preserve zero
139    /// components" behavior; when false, an all-zero CMYK source still
140    /// performs a full knockout (legacy Adobe compatibility).
141    pub opm_paired: bool,
142    /// Which CMYK channels this fill paints (bitmask of CMYK_C/M/Y/K).
143    pub painted_channels: u8,
144    /// True when color space is DeviceCMYK or ICCBased(4).
145    pub is_device_cmyk: bool,
146    /// Separation/DeviceN color for PDF output. None for device color spaces.
147    pub spot_color: Option<SpotColor>,
148    /// Rendering intent (0=RelativeColorimetric, 1=Absolute, 2=Perceptual, 3=Saturation).
149    pub rendering_intent: u8,
150    /// Pre-sampled transfer function state for PDF output.
151    pub transfer: TransferState,
152    /// Pre-computed halftone screen state for PDF output.
153    pub halftone: HalftoneState,
154    /// Pre-sampled black generation / undercolor removal for PDF output.
155    pub bg_ucr: BgUcrState,
156    /// Fill opacity (0.0–1.0, default 1.0). Used by PDF transparency.
157    pub alpha: f64,
158    /// Blend mode (0=Normal, 1=Multiply, ..., 11=Exclusion). Default 0.
159    pub blend_mode: u8,
160}
161
162/// Parameters for a text element emitted by show operators.
163///
164/// The PDF device uses these for BT/ET/Tf/Tj text operators.
165/// The raster device ignores them (uses Fill elements for glyph paths).
166#[derive(Clone, Debug)]
167pub struct TextParams {
168    /// Character bytes (or 2-byte CID values for Type 0).
169    pub text: Vec<u8>,
170    /// Device-space X position at start of string.
171    pub start_x: f64,
172    /// Device-space Y position at start of string.
173    pub start_y: f64,
174    /// Font dict entity ID (raw u32 for VM independence).
175    pub font_entity: u32,
176    /// FontName bytes (e.g., b"Times-Roman").
177    pub font_name: Vec<u8>,
178    /// FontType (0, 1, 2, 3, 42).
179    pub font_type: i32,
180    /// Effective device-space font size.
181    pub font_size: f64,
182    /// Fill color at render time.
183    pub color: DeviceColor,
184    /// CTM at render time.
185    pub ctm: [f64; 6],
186    /// User-space font matrix (scaled to point units).
187    pub font_matrix: [f64; 6],
188    /// PaintType: 0 = fill (default), 2 = stroke (outlined glyphs).
189    pub paint_type: i32,
190    /// Device-space stroke width for PaintType 2 fonts.
191    pub stroke_width: f64,
192    /// Separation/DeviceN color for PDF output. None for device color spaces.
193    pub spot_color: Option<SpotColor>,
194    /// Rendering intent (0=RelativeColorimetric, 1=Absolute, 2=Perceptual, 3=Saturation).
195    pub rendering_intent: u8,
196    /// Pre-sampled transfer function state for PDF output.
197    pub transfer: TransferState,
198    /// Pre-computed halftone screen state for PDF output.
199    pub halftone: HalftoneState,
200    /// Pre-sampled black generation / undercolor removal for PDF output.
201    pub bg_ucr: BgUcrState,
202}
203
204/// Parameters for stroking a path.
205#[derive(Clone, Debug)]
206pub struct StrokeParams {
207    pub color: DeviceColor,
208    pub line_width: f64,
209    pub line_cap: LineCap,
210    pub line_join: LineJoin,
211    pub miter_limit: f64,
212    pub dash_pattern: DashPattern,
213    pub ctm: Matrix,
214    /// When true, snap thin stroke coordinates to device pixel centers.
215    pub stroke_adjust: bool,
216    /// True when this stroke is a text glyph from a show operator (PaintType 2).
217    pub is_text_glyph: bool,
218    /// Overprint flag from graphics state (used by PDF output).
219    pub overprint: bool,
220    /// Overprint mode (0 or 1).
221    pub overprint_mode: i32,
222    /// See FillParams::opm_paired. Strict OPM-1 preserve requires both
223    /// /OPM and /op|/OP set in the same ExtGState dict.
224    pub opm_paired: bool,
225    /// Which CMYK channels this stroke paints (bitmask of CMYK_C/M/Y/K).
226    pub painted_channels: u8,
227    /// True when stroke color space is DeviceCMYK or ICCBased(4) — OPM 1 only applies to these.
228    pub is_device_cmyk: bool,
229    /// Separation/DeviceN color for PDF output. None for device color spaces.
230    pub spot_color: Option<SpotColor>,
231    /// Rendering intent (0=RelativeColorimetric, 1=Absolute, 2=Perceptual, 3=Saturation).
232    pub rendering_intent: u8,
233    /// Pre-sampled transfer function state for PDF output.
234    pub transfer: TransferState,
235    /// Pre-computed halftone screen state for PDF output.
236    pub halftone: HalftoneState,
237    /// Pre-sampled black generation / undercolor removal for PDF output.
238    pub bg_ucr: BgUcrState,
239    /// Stroke opacity (0.0–1.0, default 1.0). Used by PDF transparency.
240    pub alpha: f64,
241    /// Blend mode (0=Normal, 1=Multiply, ..., 11=Exclusion). Default 0.
242    pub blend_mode: u8,
243}
244
245/// Parameters for clipping.
246#[derive(Clone, Debug)]
247pub struct ClipParams {
248    pub fill_rule: FillRule,
249    pub ctm: Matrix,
250    /// For stroke-based clips: stroke parameters to expand the clip path
251    /// from a centerline to a stroke outline before rasterizing.
252    pub stroke_params: Option<StrokeParams>,
253}
254
255/// Pre-sampled tint transform: maps input tint values to alt-space components.
256#[derive(Clone, Debug)]
257pub struct TintLookupTable {
258    /// Number of input components (1 for Separation, N for DeviceN).
259    pub num_inputs: u32,
260    /// Number of output components (matches alternative space: 1/3/4).
261    pub num_outputs: u32,
262    /// Number of samples per dimension.
263    pub samples_per_dim: u32,
264    /// Flattened f32 data, row-major order. Length = samples_per_dim^num_inputs × num_outputs.
265    pub data: Vec<f32>,
266}
267
268impl TintLookupTable {
269    /// Linear interpolation lookup for 1D (Separation) tint transforms.
270    #[inline]
271    pub fn lookup_1d(&self, tint: f32, out: &mut [f32]) {
272        let n = self.samples_per_dim as usize;
273        let no = self.num_outputs as usize;
274        let idx = tint * (n - 1) as f32;
275        let i0 = (idx as usize).min(n - 2);
276        let frac = idx - i0 as f32;
277        let base0 = i0 * no;
278        let base1 = (i0 + 1) * no;
279        for (c, out_val) in out[..no].iter_mut().enumerate() {
280            *out_val = self.data[base0 + c] * (1.0 - frac) + self.data[base1 + c] * frac;
281        }
282    }
283
284    /// Multilinear interpolation lookup for N-D (DeviceN) tint transforms.
285    pub fn lookup_nd(&self, inputs: &[f32], out: &mut [f32]) {
286        let ni = self.num_inputs as usize;
287        let no = self.num_outputs as usize;
288        let n = self.samples_per_dim as usize;
289
290        let mut idx = [0usize; 8];
291        let mut frac = [0.0f32; 8];
292        for d in 0..ni {
293            let fi = inputs[d] * (n - 1) as f32;
294            idx[d] = (fi as usize).min(n - 2);
295            frac[d] = fi - idx[d] as f32;
296        }
297
298        let corners = 1usize << ni;
299        for out_val in out[..no].iter_mut() {
300            *out_val = 0.0;
301        }
302        for corner in 0..corners {
303            let mut weight = 1.0f32;
304            let mut linear_idx = 0usize;
305            for d in 0..ni {
306                let bit = (corner >> d) & 1;
307                let dim_idx = idx[d] + bit;
308                weight *= if bit == 1 { frac[d] } else { 1.0 - frac[d] };
309                let stride = n.pow((ni - 1 - d) as u32);
310                linear_idx += dim_idx * stride;
311            }
312            let base = linear_idx * no;
313            for (c, out_val) in out[..no].iter_mut().enumerate() {
314                *out_val += weight * self.data.get(base + c).copied().unwrap_or(0.0);
315            }
316        }
317    }
318}
319
320/// VM-free color space enum for images stored in the display list.
321#[derive(Clone, Debug)]
322pub enum ImageColorSpace {
323    DeviceGray,
324    DeviceRGB,
325    DeviceCMYK,
326    ICCBased {
327        n: u32,
328        profile_hash: ProfileHash,
329        profile_data: Arc<Vec<u8>>,
330    },
331    Indexed {
332        base: Box<ImageColorSpace>,
333        hival: u32,
334        lookup: Vec<u8>,
335    },
336    CIEBasedABC {
337        params: Arc<crate::color::CieAbcParams>,
338    },
339    CIEBasedA {
340        params: Arc<crate::color::CieAParams>,
341    },
342    /// CIE L*a*b* color space (PDF /Lab or ICCBased Lab alternate).
343    ///
344    /// Sample byte layout: 3 components (L, a, b), 8-bit each. Decode
345    /// scales bytes: L = byte/255 × 100; a = byte/255 × (`range[1]`-`range[0]`) + `range[0]`;
346    /// b = byte/255 × (`range[3]`-`range[2]`) + `range[2]`.
347    Lab {
348        white_point: [f64; 3],
349        range: [f64; 4],
350    },
351    Separation {
352        name: Vec<u8>,
353        alt_space: Box<ImageColorSpace>,
354        tint_table: Arc<TintLookupTable>,
355    },
356    DeviceN {
357        names: Vec<Vec<u8>>,
358        alt_space: Box<ImageColorSpace>,
359        tint_table: Arc<TintLookupTable>,
360    },
361    Mask {
362        color: DeviceColor,
363        polarity: bool,
364    },
365    PreconvertedRGBA,
366}
367
368impl ImageColorSpace {
369    /// Number of components per sample.
370    pub fn num_components(&self) -> u32 {
371        match self {
372            ImageColorSpace::DeviceGray => 1,
373            ImageColorSpace::DeviceRGB => 3,
374            ImageColorSpace::DeviceCMYK => 4,
375            ImageColorSpace::ICCBased { n, .. } => *n,
376            ImageColorSpace::Indexed { .. } => 1,
377            ImageColorSpace::CIEBasedABC { .. } => 3,
378            ImageColorSpace::CIEBasedA { .. } => 1,
379            ImageColorSpace::Lab { .. } => 3,
380            ImageColorSpace::Separation { .. } => 1,
381            ImageColorSpace::DeviceN { tint_table, .. } => tint_table.num_inputs,
382            ImageColorSpace::Mask { .. } => 1,
383            ImageColorSpace::PreconvertedRGBA => 4,
384        }
385    }
386}
387
388/// Parameters for drawing an image.
389#[derive(Clone, Debug)]
390pub struct ImageParams {
391    pub width: u32,
392    pub height: u32,
393    pub color_space: ImageColorSpace,
394    pub bits_per_component: u8,
395    pub ctm: Matrix,
396    pub image_matrix: Matrix,
397    pub interpolate: bool,
398    pub mask_color: Option<Vec<u8>>,
399    pub alpha: f64,
400    pub blend_mode: u8,
401    pub overprint: bool,
402    pub overprint_mode: i32,
403    /// See FillParams::opm_paired.
404    pub opm_paired: bool,
405    pub painted_channels: u8,
406}
407
408/// Color space carried through the display list for native shading output.
409#[derive(Clone, Debug)]
410pub enum ShadingColorSpace {
411    DeviceGray,
412    DeviceRGB,
413    DeviceCMYK,
414    ICCBased {
415        n: u32,
416        profile_hash: ProfileHash,
417        profile_data: Arc<Vec<u8>>,
418    },
419    CalRGB {
420        white_point: [f64; 3],
421        matrix: Option<[f64; 9]>,
422        gamma: Option<[f64; 3]>,
423    },
424    CalGray {
425        white_point: [f64; 3],
426        gamma: Option<f64>,
427    },
428}
429
430impl ShadingColorSpace {
431    /// Number of color components in this color space.
432    pub fn num_components(&self) -> usize {
433        match self {
434            ShadingColorSpace::DeviceGray | ShadingColorSpace::CalGray { .. } => 1,
435            ShadingColorSpace::DeviceRGB | ShadingColorSpace::CalRGB { .. } => 3,
436            ShadingColorSpace::DeviceCMYK => 4,
437            ShadingColorSpace::ICCBased { n, .. } => *n as usize,
438        }
439    }
440}
441
442/// A single color stop in a gradient.
443#[derive(Clone, Debug)]
444pub struct ColorStop {
445    pub position: f64,
446    pub color: DeviceColor,
447    pub raw_components: Vec<f64>,
448}
449
450/// Parameters for axial (linear) gradient shading (Type 2).
451#[derive(Clone, Debug)]
452pub struct AxialShadingParams {
453    pub x0: f64,
454    pub y0: f64,
455    pub x1: f64,
456    pub y1: f64,
457    pub color_stops: Vec<ColorStop>,
458    pub extend_start: bool,
459    pub extend_end: bool,
460    pub ctm: Matrix,
461    pub bbox: Option<[f64; 4]>,
462    pub color_space: ShadingColorSpace,
463    pub overprint: bool,
464    pub painted_channels: u8,
465    /// Fill alpha from graphics state (0.0–1.0).
466    pub alpha: f64,
467    /// True when this shading uses a Separation/DeviceN color space with a
468    /// CMYK alternate AND at least one non-process spot colorant.  The
469    /// renderer composites the per-pixel CMYK from the gradient stops with
470    /// the tracked CMYK buffer multiplicatively, preserving underlying CMYK
471    /// paints under the gradient (e.g. green checkmarks under a green→cyan
472    /// DeviceN strip survive).
473    pub spot_tint_blend: bool,
474}
475
476/// Parameters for radial gradient shading (Type 3).
477#[derive(Clone, Debug)]
478pub struct RadialShadingParams {
479    pub x0: f64,
480    pub y0: f64,
481    pub r0: f64,
482    pub x1: f64,
483    pub y1: f64,
484    pub r1: f64,
485    pub color_stops: Vec<ColorStop>,
486    pub extend_start: bool,
487    pub extend_end: bool,
488    pub ctm: Matrix,
489    pub bbox: Option<[f64; 4]>,
490    pub color_space: ShadingColorSpace,
491    pub overprint: bool,
492    pub painted_channels: u8,
493    /// Fill alpha from graphics state (0.0–1.0).
494    pub alpha: f64,
495    /// See [`AxialShadingParams::spot_tint_blend`].
496    pub spot_tint_blend: bool,
497}
498
499/// A vertex in a shading triangle mesh.
500#[derive(Clone, Debug)]
501pub struct ShadingVertex {
502    pub x: f64,
503    pub y: f64,
504    pub color: DeviceColor,
505    pub raw_components: Vec<f64>,
506}
507
508/// A triangle in a shading mesh.
509#[derive(Clone, Debug)]
510pub struct ShadingTriangle {
511    pub v0: ShadingVertex,
512    pub v1: ShadingVertex,
513    pub v2: ShadingVertex,
514}
515
516/// Parameters for Gouraud-shaded triangle mesh shading (Types 4 & 5).
517#[derive(Clone, Debug)]
518pub struct MeshShadingParams {
519    pub triangles: Vec<ShadingTriangle>,
520    pub ctm: Matrix,
521    pub bbox: Option<[f64; 4]>,
522    pub color_space: ShadingColorSpace,
523    pub overprint: bool,
524    pub painted_channels: u8,
525    /// Pre-sampled color LUT for function-based mesh shadings.
526    /// When present, vertex `raw_components[0]` holds a normalized `[0,1]`
527    /// function input. The renderer interpolates this per-pixel, then
528    /// indexes the LUT instead of Gouraud-interpolating DeviceColor.
529    pub color_lut: Option<Arc<Vec<DeviceColor>>>,
530}
531
532/// A patch in a Coons or tensor-product patch mesh.
533#[derive(Clone, Debug)]
534pub struct ShadingPatch {
535    pub points: Vec<(f64, f64)>,
536    pub colors: [DeviceColor; 4],
537    pub raw_colors: [Vec<f64>; 4],
538}
539
540/// Parameters for Coons/tensor-product patch mesh shading (Types 6 & 7).
541#[derive(Clone, Debug)]
542pub struct PatchShadingParams {
543    pub patches: Vec<ShadingPatch>,
544    pub ctm: Matrix,
545    pub bbox: Option<[f64; 4]>,
546    pub color_space: ShadingColorSpace,
547    pub overprint: bool,
548    pub painted_channels: u8,
549    /// When present, vertex `raw_colors[i][0]` holds a normalized `[0,1]`
550    /// function input. The renderer interpolates this per-pixel, then
551    /// indexes the LUT for per-pixel non-linear function evaluation.
552    pub color_lut: Option<Arc<Vec<DeviceColor>>>,
553}
554
555/// Parameters for a tiled pattern fill.
556#[derive(Clone)]
557pub struct PatternFillParams {
558    /// The path to fill with the pattern.
559    pub path: PsPath,
560    /// Fill rule for the path.
561    pub fill_rule: FillRule,
562    /// Pre-rendered display list for a single tile.
563    pub tile: DisplayList,
564    /// Pattern matrix (pattern space → device space).
565    pub pattern_matrix: Matrix,
566    /// Bounding box of one tile in pattern space.
567    pub bbox: [f64; 4],
568    /// Horizontal step between tile origins.
569    pub xstep: f64,
570    /// Vertical step between tile origins.
571    pub ystep: f64,
572    /// Paint type: 1 = colored, 2 = uncolored.
573    pub paint_type: i32,
574    /// For uncolored patterns, the fill color.
575    pub underlying_color: Option<DeviceColor>,
576    /// Unique pattern ID from pattern_store (for dedup in PDF output).
577    pub pattern_id: u32,
578    /// When true, tile display list elements have CTMs in device space
579    /// (the pattern matrix is already baked into element transforms).
580    /// When false, elements are in pattern space and the renderer applies
581    /// the pattern_matrix during rendering.
582    pub device_space_tile: bool,
583    /// When true, the tile content was designed for a Y-flipped coordinate
584    /// system (pattern matrix had negative d). The pre-rendered tile must
585    /// be vertically flipped before stamping.
586    pub flip_tile_y: bool,
587    /// For pattern strokes: stroke parameters to expand the centerline path
588    /// into a fill outline for masking. When Some, `path` is a user-space
589    /// stroke centerline rather than a fill path.
590    pub stroke_params: Option<StrokeParams>,
591    /// PDF overprint mode (0 or 1). When 1, CMYK(0,0,0,0) pixels in tile
592    /// images are transparent (no ink = don't paint).
593    pub overprint_mode: i32,
594}
595
596/// Trait for consuming rendered page pixel data.
597pub trait PageSink: Send {
598    /// Start a new page with the given pixel dimensions.
599    fn begin_page(&mut self, width: u32, height: u32) -> Result<(), String>;
600
601    /// Write one or more rows of RGBA pixel data (4 bytes per pixel, row-major).
602    fn write_rows(&mut self, rgba_rows: &[u8], num_rows: u32) -> Result<(), String>;
603
604    /// Finish the current page. May block (e.g., viewer waits for user input).
605    fn end_page(&mut self) -> Result<(), String>;
606}
607
608/// Factory for creating per-page sinks.
609pub trait PageSinkFactory: Send + Sync {
610    /// Create a new sink for a single page.
611    fn create_sink(&self, output_path: &str) -> Result<Box<dyn PageSink>, String>;
612}