Skip to main content

rpdfium_render/
render_defines.rs

1// Derived from PDFium's fpdfsdk/fpdf_view.cpp render configuration
2// Original: Copyright 2014 The PDFium Authors
3// Licensed under BSD-3-Clause / Apache-2.0
4// See pdfium-upstream/LICENSE for the original license.
5
6//! Render configuration for controlling output dimensions and background.
7
8use std::sync::Arc;
9
10use rpdfium_core::{Matrix, Rect};
11
12use crate::color_convert::RgbaColor;
13
14/// Callback for tile-based progressive rendering.
15pub trait RenderProgress: Send + Sync {
16    /// Called after each tile completes.
17    /// Returns `true` to continue, `false` to cancel rendering.
18    fn on_tile_complete(&self, tile_x: u32, tile_y: u32, total_tiles: u32) -> bool;
19}
20
21/// Forced color scheme for accessibility rendering (high-contrast mode).
22/// Corresponds to CPDF_RenderOptions::ColorScheme.
23///
24/// Note: Upstream uses 4 forced colors (path_fill, path_stroke, text_fill, text_stroke).
25/// rpdfium simplifies to 2 colors: text_color (all foreground elements) and
26/// background_color. Stroke-specific coloring is intentionally unified with fill
27/// coloring. This simplification covers all practical forced-color use cases.
28#[derive(Debug, Clone, PartialEq)]
29pub struct ColorScheme {
30    /// Color used for all foreground (text, strokes, fills) content.
31    pub text_color: RgbaColor,
32    /// Color used for page backgrounds.
33    pub background_color: RgbaColor,
34}
35
36impl ColorScheme {
37    /// Standard high-contrast: black text on white background.
38    pub fn high_contrast() -> Self {
39        Self {
40            text_color: RgbaColor {
41                r: 0,
42                g: 0,
43                b: 0,
44                a: 255,
45            },
46            background_color: RgbaColor {
47                r: 255,
48                g: 255,
49                b: 255,
50                a: 255,
51            },
52        }
53    }
54}
55
56/// Configuration for rendering a page to a bitmap.
57#[derive(Clone)]
58pub struct RenderConfig {
59    /// Output width in pixels.
60    pub width: u32,
61    /// Output height in pixels.
62    pub height: u32,
63    /// Background color.
64    pub background: RgbaColor,
65    /// Page media box in PDF user space units. When set, a page transform
66    /// is computed to map PDF coordinates (bottom-left origin, Y up) to
67    /// device coordinates (top-left origin, Y down), scaling to fit the
68    /// requested `width` × `height`.
69    ///
70    /// When `None`, an identity transform is used — PDF coordinates map
71    /// directly to pixel coordinates without Y-flip or scaling.
72    pub media_box: Option<Rect>,
73    /// Page rotation in degrees (0, 90, 180, or 270). Only used when
74    /// `media_box` is `Some`.
75    ///
76    /// **Note:** For rotated pages (90° or 270°), the caller is responsible
77    /// for swapping `width` and `height` to match the rotated dimensions.
78    pub rotation: u32,
79    /// Tile size in pixels for progressive rendering. `None` = render
80    /// full page at once (default).
81    pub tile_size: Option<u32>,
82    /// Progress callback for tile-based rendering.
83    pub progress: Option<Arc<dyn RenderProgress>>,
84    /// Convert the output to grayscale after rendering. Default: `false`.
85    pub grayscale: bool,
86    /// Enable anti-aliasing for path rendering. Default: `true`.
87    pub antialiasing: bool,
88    /// Anti-aliasing for text rendering (default: `true`).
89    /// When `false`, text glyph outlines are rendered without anti-aliasing.
90    /// Corresponds to upstream `bNoTextSmooth` (inverted).
91    pub text_antialiasing: bool,
92    /// Anti-aliasing for path/vector rendering (default: `true`).
93    /// When `false`, path fills and strokes are rendered without anti-aliasing.
94    /// Corresponds to upstream `bNoPathSmooth` (inverted).
95    pub path_antialiasing: bool,
96    /// Anti-aliasing for image scaling (default: `true`).
97    /// When `false`, images are rendered with nearest-neighbor interpolation.
98    /// Corresponds to upstream `bNoImageSmooth` (inverted).
99    pub image_antialiasing: bool,
100    /// Custom page transform matrix.  When set, this overrides the matrix
101    /// that would otherwise be computed from `media_box` and `rotation`.
102    ///
103    /// Corresponds to the `matrix` parameter of `FPDF_RenderPageBitmapWithMatrix`.
104    pub custom_transform: Option<Matrix>,
105    /// Device-space clip rectangle in pixel coordinates, where `(0, 0)` is the
106    /// top-left corner of the bitmap, x increases rightward, and y increases
107    /// downward.  The `Rect` fields map as: `left`/`right` for the x range and
108    /// `bottom`/`top` for the y range (both inclusive-exclusive).  Pixels
109    /// outside this rectangle are replaced by the `background` color after
110    /// rendering.  `None` (default) means the full `width × height` surface is
111    /// used.
112    ///
113    /// Corresponds to the `clipping` parameter of `FPDF_RenderPageBitmapWithMatrix`.
114    pub clip_rect: Option<Rect>,
115    /// If set, all fill/stroke colors are replaced with this scheme (accessibility mode).
116    /// Corresponds to CPDF_RenderOptions::kForcedColor.
117    pub forced_color_scheme: Option<ColorScheme>,
118}
119
120impl std::fmt::Debug for RenderConfig {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        f.debug_struct("RenderConfig")
123            .field("width", &self.width)
124            .field("height", &self.height)
125            .field("background", &self.background)
126            .field("media_box", &self.media_box)
127            .field("rotation", &self.rotation)
128            .field("tile_size", &self.tile_size)
129            .field("progress", &self.progress.as_ref().map(|_| "..."))
130            .field("grayscale", &self.grayscale)
131            .field("antialiasing", &self.antialiasing)
132            .field("text_antialiasing", &self.text_antialiasing)
133            .field("path_antialiasing", &self.path_antialiasing)
134            .field("image_antialiasing", &self.image_antialiasing)
135            .field("custom_transform", &self.custom_transform)
136            .field("clip_rect", &self.clip_rect)
137            .field("forced_color_scheme", &self.forced_color_scheme)
138            .finish()
139    }
140}
141
142impl RenderConfig {
143    /// Set the output dimensions in pixels.
144    pub fn with_size(mut self, width: u32, height: u32) -> Self {
145        self.width = width;
146        self.height = height;
147        self
148    }
149
150    /// Set the background color.
151    pub fn with_background(mut self, bg: RgbaColor) -> Self {
152        self.background = bg;
153        self
154    }
155
156    /// Set the page media box for coordinate transformation.
157    pub fn with_media_box(mut self, media_box: Rect) -> Self {
158        self.media_box = Some(media_box);
159        self
160    }
161
162    /// Set the page rotation in degrees (0, 90, 180, or 270).
163    pub fn with_rotation(mut self, rotation: u32) -> Self {
164        self.rotation = rotation;
165        self
166    }
167
168    /// Set the tile size in pixels for progressive rendering.
169    pub fn with_tile_size(mut self, size: u32) -> Self {
170        self.tile_size = Some(size);
171        self
172    }
173
174    /// Set the progress callback for tile-based rendering.
175    pub fn with_progress(mut self, progress: Arc<dyn RenderProgress>) -> Self {
176        self.progress = Some(progress);
177        self
178    }
179
180    /// Enable or disable grayscale output conversion.
181    pub fn with_grayscale(mut self, grayscale: bool) -> Self {
182        self.grayscale = grayscale;
183        self
184    }
185
186    /// Enable or disable anti-aliasing.
187    pub fn with_antialiasing(mut self, antialiasing: bool) -> Self {
188        self.antialiasing = antialiasing;
189        self
190    }
191
192    /// Enable or disable anti-aliasing specifically for text rendering.
193    pub fn with_text_antialiasing(mut self, aa: bool) -> Self {
194        self.text_antialiasing = aa;
195        self
196    }
197
198    /// Enable or disable anti-aliasing specifically for path/vector rendering.
199    pub fn with_path_antialiasing(mut self, aa: bool) -> Self {
200        self.path_antialiasing = aa;
201        self
202    }
203
204    /// Enable or disable anti-aliasing specifically for image scaling.
205    pub fn with_image_antialiasing(mut self, aa: bool) -> Self {
206        self.image_antialiasing = aa;
207        self
208    }
209
210    /// Set a custom page transform matrix, bypassing the `media_box`/`rotation`
211    /// calculation.
212    ///
213    /// The matrix maps PDF user-space coordinates to device pixel coordinates.
214    /// When set, `media_box` and `rotation` are ignored.
215    ///
216    /// Corresponds to `FPDF_RenderPageBitmapWithMatrix`.
217    pub fn with_transform(mut self, matrix: Matrix) -> Self {
218        self.custom_transform = Some(matrix);
219        self
220    }
221
222    /// Set a device-space clip rectangle.
223    ///
224    /// Pixels outside `rect` are replaced with the `background` color after
225    /// rendering.  Coordinates are in device pixels with the top-left corner
226    /// at `(0, 0)`.
227    ///
228    /// Corresponds to the `clipping` parameter of `FPDF_RenderPageBitmapWithMatrix`.
229    pub fn with_clip(mut self, rect: Rect) -> Self {
230        self.clip_rect = Some(rect);
231        self
232    }
233
234    /// Enable forced color mode for accessibility rendering.
235    ///
236    /// When set, all text, stroke, and fill colors are replaced with the
237    /// specified colors.  Image content is not affected.
238    ///
239    /// Corresponds to `CPDF_RenderOptions::kForcedColor`.
240    pub fn with_forced_colors(mut self, text: RgbaColor, background: RgbaColor) -> Self {
241        self.forced_color_scheme = Some(ColorScheme {
242            text_color: text,
243            background_color: background,
244        });
245        self
246    }
247}
248
249impl Default for RenderConfig {
250    fn default() -> Self {
251        Self {
252            width: 612,
253            height: 792,
254            background: RgbaColor::WHITE,
255            media_box: None,
256            rotation: 0,
257            tile_size: None,
258            progress: None,
259            grayscale: false,
260            antialiasing: true,
261            text_antialiasing: true,
262            path_antialiasing: true,
263            image_antialiasing: true,
264            custom_transform: None,
265            clip_rect: None,
266            forced_color_scheme: None,
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_default_config() {
277        let config = RenderConfig::default();
278        assert_eq!(config.width, 612);
279        assert_eq!(config.height, 792);
280        assert_eq!(config.background, RgbaColor::WHITE);
281        assert!(config.media_box.is_none());
282        assert_eq!(config.rotation, 0);
283        assert!(config.tile_size.is_none());
284        assert!(config.progress.is_none());
285        assert!(!config.grayscale);
286        assert!(config.antialiasing);
287        assert!(config.text_antialiasing);
288        assert!(config.path_antialiasing);
289        assert!(config.image_antialiasing);
290    }
291
292    #[test]
293    fn test_builder_with_size() {
294        let config = RenderConfig::default().with_size(1024, 768);
295        assert_eq!(config.width, 1024);
296        assert_eq!(config.height, 768);
297    }
298
299    #[test]
300    fn test_builder_with_background() {
301        let bg = RgbaColor {
302            r: 255,
303            g: 0,
304            b: 0,
305            a: 255,
306        };
307        let config = RenderConfig::default().with_background(bg);
308        assert_eq!(config.background.r, 255);
309        assert_eq!(config.background.g, 0);
310    }
311
312    #[test]
313    fn test_builder_with_media_box() {
314        let rect = Rect::new(0.0, 0.0, 612.0, 792.0);
315        let config = RenderConfig::default().with_media_box(rect);
316        assert!(config.media_box.is_some());
317        let mb = config.media_box.unwrap();
318        assert_eq!(mb.right, 612.0);
319        assert_eq!(mb.top, 792.0);
320    }
321
322    #[test]
323    fn test_builder_with_rotation() {
324        let config = RenderConfig::default().with_rotation(90);
325        assert_eq!(config.rotation, 90);
326    }
327
328    #[test]
329    fn test_builder_chaining() {
330        let config = RenderConfig::default()
331            .with_size(800, 600)
332            .with_rotation(180)
333            .with_background(RgbaColor {
334                r: 0,
335                g: 0,
336                b: 0,
337                a: 255,
338            });
339        assert_eq!(config.width, 800);
340        assert_eq!(config.height, 600);
341        assert_eq!(config.rotation, 180);
342        assert_eq!(config.background.r, 0);
343    }
344
345    #[test]
346    fn test_config_is_send_sync() {
347        fn assert_send_sync<T: Send + Sync>() {}
348        assert_send_sync::<RenderConfig>();
349    }
350
351    #[test]
352    fn test_builder_with_tile_size() {
353        let config = RenderConfig::default().with_tile_size(256);
354        assert_eq!(config.tile_size, Some(256));
355    }
356
357    #[test]
358    fn test_builder_with_progress() {
359        struct TestProgress;
360        impl RenderProgress for TestProgress {
361            fn on_tile_complete(&self, _tx: u32, _ty: u32, _total: u32) -> bool {
362                true
363            }
364        }
365        let config = RenderConfig::default().with_progress(Arc::new(TestProgress));
366        assert!(config.progress.is_some());
367    }
368
369    #[test]
370    fn test_builder_with_grayscale() {
371        let config = RenderConfig::default().with_grayscale(true);
372        assert!(config.grayscale);
373    }
374
375    #[test]
376    fn test_builder_with_antialiasing() {
377        let config = RenderConfig::default().with_antialiasing(false);
378        assert!(!config.antialiasing);
379    }
380
381    #[test]
382    fn test_grayscale_conversion() {
383        // Verify the grayscale formula: gray = (r*299 + g*587 + b*114) / 1000
384        let r: u32 = 255;
385        let g: u32 = 0;
386        let b: u32 = 0;
387        let gray = (r * 299 + g * 587 + b * 114) / 1000;
388        assert_eq!(gray, 76); // Pure red → grayish
389
390        let gray_white = (255 * 299 + 255 * 587 + 255 * 114) / 1000;
391        assert_eq!(gray_white, 255);
392
393        let gray_black = 0; // Black: (0 * 299 + 0 * 587 + 0 * 114) / 1000
394        assert_eq!(gray_black, 0);
395    }
396
397    #[test]
398    fn test_color_scheme_high_contrast() {
399        let scheme = ColorScheme::high_contrast();
400        assert_eq!(
401            scheme.text_color,
402            RgbaColor {
403                r: 0,
404                g: 0,
405                b: 0,
406                a: 255
407            }
408        );
409        assert_eq!(
410            scheme.background_color,
411            RgbaColor {
412                r: 255,
413                g: 255,
414                b: 255,
415                a: 255
416            }
417        );
418    }
419
420    #[test]
421    fn test_render_config_with_forced_colors_builder() {
422        let text = RgbaColor {
423            r: 255,
424            g: 255,
425            b: 0,
426            a: 255,
427        };
428        let bg = RgbaColor {
429            r: 0,
430            g: 0,
431            b: 128,
432            a: 255,
433        };
434        let config = RenderConfig::default().with_forced_colors(text, bg);
435        assert!(config.forced_color_scheme.is_some());
436        let scheme = config.forced_color_scheme.unwrap();
437        assert_eq!(scheme.text_color.r, 255);
438        assert_eq!(scheme.text_color.g, 255);
439        assert_eq!(scheme.background_color.b, 128);
440    }
441
442    #[test]
443    fn test_default_config_has_no_forced_colors() {
444        let config = RenderConfig::default();
445        assert!(config.forced_color_scheme.is_none());
446    }
447
448    #[test]
449    fn test_render_config_per_feature_aa_defaults() {
450        let config = RenderConfig::default();
451        assert!(config.text_antialiasing);
452        assert!(config.path_antialiasing);
453        assert!(config.image_antialiasing);
454    }
455
456    #[test]
457    fn test_render_config_with_path_antialiasing_false_builder() {
458        let config = RenderConfig::default().with_path_antialiasing(false);
459        assert!(!config.path_antialiasing);
460        // Other per-feature AA flags should remain at defaults
461        assert!(config.text_antialiasing);
462        assert!(config.image_antialiasing);
463    }
464}