Skip to main content

svg_renderer/
options.rs

1use skia_safe::{Color, jpeg_encoder, webp_encoder};
2
3use crate::SvgRenderError;
4
5/// Maximum size of a rendered RGBA buffer.
6pub(crate) const MAX_RENDER_BYTES: usize = 512 * 1024 * 1024;
7
8/// Non-zero pixel dimensions for the output raster image.
9///
10/// Width and height must be `> 0` and `<= i32::MAX` to satisfy Skia's
11/// internal surface constraints. The total RGBA buffer must also fit
12/// within the crate's maximum render allocation.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct RenderSize {
15    pub width: u32,
16    pub height: u32,
17}
18
19impl RenderSize {
20    /// Creates a new size after validating the dimensions.
21    ///
22    /// # Errors
23    /// Returns [`SvgRenderError::InvalidSize`] if either dimension is zero
24    /// or exceeds `i32::MAX`, or if the resulting RGBA buffer would be too large.
25    pub fn new(width: u32, height: u32) -> Result<Self, SvgRenderError> {
26        if width == 0 || height == 0 {
27            return Err(SvgRenderError::InvalidSize { width, height });
28        }
29
30        if width > i32::MAX as u32 || height > i32::MAX as u32 {
31            return Err(SvgRenderError::InvalidSize { width, height });
32        }
33
34        let byte_len = (width as usize)
35            .checked_mul(height as usize)
36            .and_then(|pixels| pixels.checked_mul(4))
37            .ok_or(SvgRenderError::InvalidSize { width, height })?;
38        if byte_len > MAX_RENDER_BYTES {
39            return Err(SvgRenderError::InvalidSize { width, height });
40        }
41
42        Ok(Self { width, height })
43    }
44
45    /// Converts to `(i32, i32)` for Skia API calls.
46    pub(crate) fn as_i32_pair(self) -> (i32, i32) {
47        (self.width as i32, self.height as i32)
48    }
49}
50
51/// Parameters controlling a single SVG render call.
52///
53/// # Defaults
54/// | Field          | Default         |
55/// |----------------|-----------------|
56/// | `clear_color`  | `TRANSPARENT`   |
57/// | `sample_count` | 4 (MSAA)        |
58#[derive(Debug, Clone)]
59pub struct RenderOptions {
60    /// Output image dimensions.
61    pub size: RenderSize,
62    /// Background color before rendering the SVG onto the canvas.
63    pub clear_color: Color,
64    /// MSAA sample count (GPU only; ignored by CPU backend).
65    pub sample_count: usize,
66}
67
68impl RenderOptions {
69    /// Creates options for the given output size with defaults for
70    /// clear color (transparent) and MSAA (4×).
71    pub fn new(width: u32, height: u32) -> Result<Self, SvgRenderError> {
72        Ok(Self {
73            size: RenderSize::new(width, height)?,
74            clear_color: Color::TRANSPARENT,
75            sample_count: 4,
76        })
77    }
78}
79
80/// JPEG encoding parameters.
81///
82/// Default: quality 90, chroma subsampling in both directions, alpha ignored.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct JpegOptions {
85    /// Encoding quality (0–100, clamped by the Skia converter).
86    pub quality: u32,
87    /// Chroma subsampling mode.
88    pub downsample: JpegDownsample,
89    /// How to handle the alpha channel (JPEG does not support alpha).
90    pub alpha_option: JpegAlphaOption,
91}
92
93impl Default for JpegOptions {
94    fn default() -> Self {
95        Self {
96            quality: 90,
97            downsample: JpegDownsample::BothDirections,
98            alpha_option: JpegAlphaOption::Ignore,
99        }
100    }
101}
102
103/// JPEG chroma subsampling.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum JpegDownsample {
106    /// Subsample horizontally and vertically (4:2:0).
107    BothDirections,
108    /// Subsample horizontally only (4:2:2).
109    Horizontal,
110    /// No subsampling (4:4:4).
111    No,
112}
113
114/// How to encode alpha-containing images as JPEG (which has no alpha).
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum JpegAlphaOption {
117    /// Discard the alpha channel.
118    Ignore,
119    /// Blend against black before encoding.
120    BlendOnBlack,
121}
122
123impl From<JpegOptions> for jpeg_encoder::Options {
124    fn from(value: JpegOptions) -> Self {
125        let downsample = match value.downsample {
126            JpegDownsample::BothDirections => jpeg_encoder::Downsample::BothDirections,
127            JpegDownsample::Horizontal => jpeg_encoder::Downsample::Horizontal,
128            JpegDownsample::No => jpeg_encoder::Downsample::No,
129        };
130        let alpha_option = match value.alpha_option {
131            JpegAlphaOption::Ignore => jpeg_encoder::AlphaOption::Ignore,
132            JpegAlphaOption::BlendOnBlack => jpeg_encoder::AlphaOption::BlendOnBlack,
133        };
134
135        Self {
136            quality: value.quality.clamp(0, 100),
137            downsample,
138            alpha_option,
139            ..jpeg_encoder::Options::default()
140        }
141    }
142}
143
144/// WebP encoding parameters.
145///
146/// Default: lossy at quality 90.
147#[derive(Debug, Clone, Copy, PartialEq)]
148pub struct WebpOptions {
149    /// Compression mode: lossy or lossless.
150    pub compression: WebpCompression,
151    /// Encoding quality (0.0–100.0, clamped). Meaningless for lossless.
152    pub quality: f32,
153}
154
155impl Default for WebpOptions {
156    fn default() -> Self {
157        Self {
158            compression: WebpCompression::Lossy,
159            quality: 90.0,
160        }
161    }
162}
163
164/// WebP compression mode.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum WebpCompression {
167    /// Lossy compression (VP8).
168    Lossy,
169    /// Lossless compression (VP8L).
170    Lossless,
171}
172
173impl From<WebpOptions> for webp_encoder::Options {
174    fn from(value: WebpOptions) -> Self {
175        let compression = match value.compression {
176            WebpCompression::Lossy => webp_encoder::Compression::Lossy,
177            WebpCompression::Lossless => webp_encoder::Compression::Lossless,
178        };
179
180        Self {
181            compression,
182            quality: value.quality.clamp(0.0, 100.0),
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn rejects_zero_sized_render_targets() {
193        assert!(matches!(
194            RenderSize::new(0, 32),
195            Err(SvgRenderError::InvalidSize { .. })
196        ));
197        assert!(matches!(
198            RenderSize::new(32, 0),
199            Err(SvgRenderError::InvalidSize { .. })
200        ));
201    }
202
203    #[test]
204    fn rejects_render_targets_that_would_allocate_too_many_bytes() {
205        assert!(matches!(
206            RenderSize::new(i32::MAX as u32, i32::MAX as u32),
207            Err(SvgRenderError::InvalidSize { .. })
208        ));
209    }
210
211    #[test]
212    fn default_render_options_are_valid() {
213        let options = RenderOptions::new(64, 48).unwrap();
214
215        assert_eq!(options.size.width, 64);
216        assert_eq!(options.size.height, 48);
217        assert_eq!(options.sample_count, 4);
218    }
219}