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}