Skip to main content

pdfrum_render/
options.rs

1//! What a caller can ask of a render, and the defaults that reproduce the
2//! oracle.
3//!
4//! The conformance corpus renders with annotations on and nothing else, so
5//! the whole option surface collapses to its defaults.
6
7// `pdfium_test` renders every golden with `flags = FPDF_ANNOT` and nothing
8// else: colour mode normal, no forced colours, and — the one that surprises
9// — `bClearType = false`, because `RenderPageImpl` overwrites the
10// constructor's `true` from the flag word on every public render call.
11//
12// **`bClearType = false` does not mean the LCD path is off.** It sets
13// `CFX_TextRenderOptions::aliasing_type` to `kAntiAliasing`, and that is a
14// different variable from `FontAntiAliasingMode`, which `DrawNormalText`
15// derives separately (`cfx_renderdevice.cpp:1165-1206`): on a display device
16// at 32 bpp with a smooth aliasing type the mode is `kLcd` *whatever*
17// `bClearType` said, and `aliasing_type` only decides `normalize`. It matters
18// for exactly one thing — the glyph-origin snap floors in x under `kLcd` and
19// rounds under `kMono`.
20
21use kurbo::Affine;
22
23use crate::color::Argb;
24
25/// The four colour modes. Three are reachable without any caller asking:
26/// `Alpha` from an alpha-type soft mask and from an uncoloured tiling
27/// pattern, `Gray` from a grayscale render, `Forced` from a colour scheme.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum ColorMode {
30    /// Colours pass through unchanged.
31    #[default]
32    Normal,
33    /// Every path and text colour collapses to its NTSC gray.
34    Gray,
35    /// Drawing writes *alpha as gray*: the mode a soft mask's alpha group and
36    /// an uncoloured tile render in.
37    Alpha,
38    /// Path and text colours are replaced wholesale; images, shadings and
39    /// forms keep their own and are not gray-translated either.
40    Forced(ColorScheme),
41}
42
43/// The four replacement colours a forced-colour render substitutes.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct ColorScheme {
46    /// Replaces every path fill.
47    pub path_fill: Argb,
48    /// Replaces every path stroke.
49    pub path_stroke: Argb,
50    /// Replaces every text fill.
51    pub text_fill: Argb,
52    /// Replaces every text stroke.
53    pub text_stroke: Argb,
54}
55
56/// How glyphs are antialiased.
57///
58/// [`TextAa::Grayscale`] is the oracle's own choice on an ordinary page: the
59/// three subpixel coverages are averaged back to grey. [`TextAa::LcdSubpixel`]
60/// is reachable, but only per-draw — see its own note.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum TextAa {
63    /// Grayscale coverage — the oracle's conformance setting.
64    #[default]
65    Grayscale,
66    /// Hard-edged glyph fills.
67    None,
68    /// `ClearType`: each pixel's three LCD stripes keep their own coverage and
69    /// merge into their own destination channel, so a glyph drawn in one colour
70    /// carries colour fringes.
71    ///
72    /// It is **not** something only a caller asks for: a live edit's text —
73    /// and only that text — is drawn with `ClearType` on while the rest of
74    /// the page is not, which is why this is a *per-draw* selection rather
75    /// than a whole-render one. See [`RenderOptions::text_aa_override`].
76    LcdSubpixel,
77}
78
79/// Everything a render is parameterised by.
80///
81/// Constructed with `..RenderOptions::default()` struct update. The defaults
82/// are the oracle's: reproducing a golden needs no configuration beyond the
83/// target size.
84#[derive(Debug, PartialEq)]
85#[expect(
86    clippy::struct_excessive_bools,
87    reason = "these are `CPDF_RenderOptions::Options`' independent bit flags \
88              one for one; grouping them into sub-structs or an enum would \
89              hide which upstream flag each is and make the port unreviewable"
90)]
91pub struct RenderOptions {
92    /// Page space to device space. `render_page` composes this with the
93    /// page's own display matrix.
94    pub transform: Affine,
95    /// The colour mode, normally [`ColorMode::Normal`].
96    pub color_mode: ColorMode,
97    /// Glyph antialiasing, for the whole render.
98    pub text_aa: TextAa,
99    /// Glyph antialiasing for *this draw only*, overriding [`Self::text_aa`].
100    ///
101    /// Text antialiasing is per-draw, not per-render: on a page whose every
102    /// other run is grayscale, a live edit's text is drawn with `ClearType`.
103    ///
104    /// `None` — the default — means [`Self::text_aa`] decides. A caller
105    /// drawing one run differently sets this on a clone of its options for
106    /// that run, so the page's own setting stays readable.
107    pub text_aa_override: Option<TextAa>,
108    /// `bNoPathSmooth`: hard-edge every path fill and stroke.
109    pub no_path_smooth: bool,
110    /// `bNoImageSmooth`: never interpolate an image, whatever `/Interpolate`
111    /// or the size heuristic say. Wins over both.
112    pub no_image_smooth: bool,
113    /// `bForceHalftone`. Forced on inside type-3 char procs and uncoloured
114    /// tile cells, so it reaches a render even with the oracle's bare flags.
115    pub force_halftone: bool,
116    /// `bRectAA`: antialias axis-aligned rectangle fills instead of
117    /// integer-snapping them. Forced on inside type-3 char procs.
118    pub rect_aa: bool,
119    /// `bConvertFillToStroke`, read only under a forced colour scheme.
120    pub convert_fill_to_stroke: bool,
121    /// Place each glyph at its true fractional device origin instead of
122    /// snapping it to a whole pixel.
123    ///
124    /// **Default `false`, which is the oracle**: small text is placed on a
125    /// grid of whole pixels in y and thirds of a pixel in x. Set this to
126    /// `true` when a caller wants text where the PDF actually puts it —
127    /// smooth animation, a non-integer device scale, any use where oracle
128    /// parity is not the goal — at the cost of matching a golden.
129    ///
130    /// No effect on large text: above the size threshold glyphs are placed
131    /// fractionally either way.
132    pub subpixel_text_positioning: bool,
133    /// The page background. `None` follows the oracle: opaque white for a
134    /// page without transparency, fully transparent for one with it.
135    ///
136    /// This is load-bearing rather than cosmetic — a white-backed page
137    /// composites differently at the edges from a transparent-backed one, and
138    /// the engine's single compositing path relies on the white being real
139    /// pixels where the oracle would have replayed a white backdrop.
140    pub background: Option<peniko::Color>,
141}
142
143// Hand-written so that the nested contexts a walk builds — a form's, a char
144// proc's, a tile cell's, a soft mask's, and one per *run* of a text clip —
145// are countable at a single point. The body is `*self`: every field is
146// `Copy`, so deriving `Clone` would produce exactly this code and would leave
147// the count unanswerable.
148impl Clone for RenderOptions {
149    fn clone(&self) -> Self {
150        crate::walkprofile::alloc_items(
151            crate::walkprofile::Site::OptionsClone,
152            1,
153            core::mem::size_of::<Self>(),
154        );
155        Self { ..*self }
156    }
157}
158
159impl Default for RenderOptions {
160    fn default() -> Self {
161        Self {
162            transform: Affine::IDENTITY,
163            color_mode: ColorMode::Normal,
164            text_aa: TextAa::Grayscale,
165            text_aa_override: None,
166            no_path_smooth: false,
167            no_image_smooth: false,
168            force_halftone: false,
169            rect_aa: false,
170            convert_fill_to_stroke: false,
171            subpixel_text_positioning: false,
172            background: None,
173        }
174    }
175}
176
177impl RenderOptions {
178    /// The background a page with the given transparency renders onto:
179    /// opaque white when the page is opaque, fully transparent when it is
180    /// not, unless [`RenderOptions::background`] overrides it.
181    #[must_use]
182    pub fn background_for(&self, has_transparency: bool) -> peniko::Color {
183        self.background.unwrap_or(if has_transparency {
184            peniko::Color::TRANSPARENT
185        } else {
186            peniko::Color::WHITE
187        })
188    }
189
190    /// Whether path geometry is antialiased under these options.
191    #[must_use]
192    pub fn path_aa(&self) -> crate::device::AntiAlias {
193        if self.no_path_smooth {
194            crate::device::AntiAlias::Off
195        } else {
196            crate::device::AntiAlias::On
197        }
198    }
199
200    /// The text antialiasing in force for the draw being made: the per-draw
201    /// [`Self::text_aa_override`] when a caller set one, else [`Self::text_aa`].
202    #[must_use]
203    pub fn effective_text_aa(&self) -> TextAa {
204        self.text_aa_override.unwrap_or(self.text_aa)
205    }
206
207    /// The options one text run draws under with `aa` forced, leaving the
208    /// page's own options untouched.
209    ///
210    /// This is what a live edit's text draws through.
211    #[must_use]
212    pub fn for_text_run(&self, aa: TextAa) -> Self {
213        Self {
214            text_aa_override: Some(aa),
215            ..self.clone()
216        }
217    }
218
219    /// Whether glyph *outlines* are antialiased under these options.
220    ///
221    /// The outline path has no subpixel spelling, so [`TextAa::LcdSubpixel`]
222    /// antialiases here exactly like [`TextAa::Grayscale`]. The subpixel
223    /// choice is expressed on the *bitmap* path, the only place the oracle
224    /// expresses it either.
225    #[must_use]
226    pub fn text_antialias(&self) -> crate::device::AntiAlias {
227        match self.effective_text_aa() {
228            TextAa::Grayscale | TextAa::LcdSubpixel => crate::device::AntiAlias::On,
229            TextAa::None => crate::device::AntiAlias::Off,
230        }
231    }
232
233    /// The options a type-3 char proc renders under: forced halftone and
234    /// rectangle antialiasing, which means a rectangle inside a type-3 glyph
235    /// *is* antialiased where the same rectangle on the page would not be.
236    #[must_use]
237    pub fn for_type3_char_proc(&self) -> Self {
238        Self {
239            force_halftone: true,
240            rect_aa: true,
241            ..self.clone()
242        }
243    }
244
245    /// The options an uncoloured tiling pattern's cell renders under: alpha
246    /// colour mode plus forced halftone.
247    #[must_use]
248    pub fn for_uncolored_tile(&self) -> Self {
249        Self {
250            color_mode: ColorMode::Alpha,
251            force_halftone: true,
252            ..self.clone()
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn oracle_defaults() {
263        let o = RenderOptions::default();
264        // The whole point: `pdfium_test` passes FPDF_ANNOT and nothing else,
265        // so bClearType is false and text is plain grayscale-antialiased.
266        assert_eq!(o.text_aa, TextAa::Grayscale);
267        assert_eq!(o.color_mode, ColorMode::Normal);
268        assert!(!o.rect_aa);
269        assert!(!o.force_halftone);
270    }
271
272    #[test]
273    fn background_follows_page_transparency() {
274        let o = RenderOptions::default();
275        assert_eq!(o.background_for(false), peniko::Color::WHITE);
276        assert_eq!(o.background_for(true), peniko::Color::TRANSPARENT);
277        let forced = RenderOptions {
278            background: Some(peniko::Color::BLACK),
279            ..RenderOptions::default()
280        };
281        assert_eq!(forced.background_for(true), peniko::Color::BLACK);
282    }
283
284    #[test]
285    fn type3_forces_rect_aa_and_halftone() {
286        let inner = RenderOptions::default().for_type3_char_proc();
287        assert!(inner.rect_aa);
288        assert!(inner.force_halftone);
289    }
290
291    #[test]
292    fn a_per_draw_override_wins_over_the_render_wide_setting() {
293        // The shape the oracle's `DrawTextString` has: one run drawn with
294        // ClearType on a page whose every other run is grayscale.
295        let page = RenderOptions::default();
296        assert_eq!(page.effective_text_aa(), TextAa::Grayscale);
297        let run = page.for_text_run(TextAa::LcdSubpixel);
298        assert_eq!(run.effective_text_aa(), TextAa::LcdSubpixel);
299        // And the page's own setting is untouched, which is what makes this an
300        // override rather than a mutation.
301        assert_eq!(page.effective_text_aa(), TextAa::Grayscale);
302        assert_eq!(run.text_aa, TextAa::Grayscale);
303    }
304
305    #[test]
306    fn the_subpixel_mode_still_antialiases_outlines() {
307        // Above the size threshold the oracle abandons bitmaps for
308        // `DrawTextPath`, which reads only `!is_text_smooth` — so ClearType and
309        // grayscale fill an outline identically and only `None` hard-edges it.
310        let lcd = RenderOptions::default().for_text_run(TextAa::LcdSubpixel);
311        assert_eq!(lcd.text_antialias(), crate::device::AntiAlias::On);
312        let off = RenderOptions::default().for_text_run(TextAa::None);
313        assert_eq!(off.text_antialias(), crate::device::AntiAlias::Off);
314    }
315
316    #[test]
317    fn uncolored_tile_renders_in_alpha_mode() {
318        let inner = RenderOptions::default().for_uncolored_tile();
319        assert_eq!(inner.color_mode, ColorMode::Alpha);
320        assert!(inner.force_halftone);
321    }
322}