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