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}