Skip to main content

firecrawl_pdfium/
render.rs

1//! Rendering: configuration, execution, and owned results.
2
3use std::ffi::c_int;
4
5use crate::coords::PageTransform;
6use crate::error::{Error, Result};
7use crate::page::{PdfPage, Rotation};
8use crate::sys;
9
10/// Pixel layout of a rendered bitmap.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12#[non_exhaustive]
13pub enum PixelFormat {
14    /// 4 bytes/pixel: blue, green, red, alpha — straight (non-premultiplied)
15    /// alpha. PDFium's native format.
16    #[default]
17    Bgra8,
18    /// 4 bytes/pixel: red, green, blue, alpha — straight alpha. Rendered by
19    /// PDFium with its reverse-byte-order flag; convenient for `image`/PNG
20    /// interop.
21    Rgba8,
22    /// 3 bytes/pixel: blue, green, red. No alpha.
23    Bgr8,
24    /// 1 byte/pixel grayscale.
25    Gray8,
26}
27
28impl PixelFormat {
29    /// Bytes per pixel.
30    pub fn bytes_per_pixel(self) -> usize {
31        match self {
32            PixelFormat::Bgra8 | PixelFormat::Rgba8 => 4,
33            PixelFormat::Bgr8 => 3,
34            PixelFormat::Gray8 => 1,
35        }
36    }
37
38    /// Whether the format carries an alpha channel.
39    pub fn has_alpha(self) -> bool {
40        matches!(self, PixelFormat::Bgra8 | PixelFormat::Rgba8)
41    }
42
43    fn as_fpdf(self) -> c_int {
44        match self {
45            // Rgba8 is BGRA storage rendered with FPDF_REVERSE_BYTE_ORDER.
46            PixelFormat::Bgra8 | PixelFormat::Rgba8 => sys::FPDFBitmap_BGRA,
47            PixelFormat::Bgr8 => sys::FPDFBitmap_BGR,
48            PixelFormat::Gray8 => sys::FPDFBitmap_Gray,
49        }
50    }
51}
52
53/// An sRGB color with straight alpha, used for render backgrounds.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct Color {
56    /// Red.
57    pub r: u8,
58    /// Green.
59    pub g: u8,
60    /// Blue.
61    pub b: u8,
62    /// Alpha (255 = opaque), straight (non-premultiplied).
63    pub a: u8,
64}
65
66impl Color {
67    /// Opaque white — the default render background.
68    pub const WHITE: Color = Color::rgb(0xFF, 0xFF, 0xFF);
69    /// Opaque black.
70    pub const BLACK: Color = Color::rgb(0x00, 0x00, 0x00);
71    /// Fully transparent — use with an alpha [`PixelFormat`] for
72    /// compositing.
73    pub const TRANSPARENT: Color = Color {
74        r: 0,
75        g: 0,
76        b: 0,
77        a: 0,
78    };
79
80    /// An opaque color from RGB components.
81    pub const fn rgb(r: u8, g: u8, b: u8) -> Color {
82        Color { r, g, b, a: 0xFF }
83    }
84
85    /// A color from RGBA components (straight alpha).
86    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color {
87        Color { r, g, b, a }
88    }
89
90    /// Rec. 601 luma, used when encoding into grayscale buffers.
91    fn luma(self) -> u8 {
92        let y = 0.299 * f32::from(self.r) + 0.587 * f32::from(self.g) + 0.114 * f32::from(self.b);
93        y.round().clamp(0.0, 255.0) as u8
94    }
95
96    /// Encodes into the in-memory byte pattern for one pixel of `format`.
97    fn encode(self, format: PixelFormat) -> ([u8; 4], usize) {
98        match format {
99            PixelFormat::Bgra8 => ([self.b, self.g, self.r, self.a], 4),
100            PixelFormat::Rgba8 => ([self.r, self.g, self.b, self.a], 4),
101            PixelFormat::Bgr8 => ([self.b, self.g, self.r, 0], 3),
102            PixelFormat::Gray8 => ([self.luma(), 0, 0, 0], 1),
103        }
104    }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq)]
108enum SizeSpec {
109    /// Multiply page points by this factor.
110    Scale(f32),
111    /// Fixed output width in pixels; height keeps the aspect ratio.
112    Width(u32),
113    /// Fixed output height in pixels; width keeps the aspect ratio.
114    Height(u32),
115    /// Largest size that fits within the box while keeping aspect ratio.
116    Fit(u32, u32),
117    /// Exact output dimensions; aspect ratio may change.
118    Exact(u32, u32),
119}
120
121/// Configuration for [`PdfPage::render`].
122///
123/// ```
124/// use firecrawl_pdfium::{RenderConfig, PixelFormat};
125///
126/// // 300 DPI grayscale, annotations off:
127/// let config = RenderConfig::new()
128///     .dpi(300.0)
129///     .pixel_format(PixelFormat::Gray8)
130///     .annotations(false);
131/// ```
132#[derive(Debug, Clone, PartialEq)]
133pub struct RenderConfig {
134    size: SizeSpec,
135    format: PixelFormat,
136    background: Color,
137    annotations: bool,
138    form_fields: bool,
139    extra_rotation: Rotation,
140    text_antialiasing: bool,
141    image_antialiasing: bool,
142    path_antialiasing: bool,
143    max_output_bytes: u64,
144}
145
146impl Default for RenderConfig {
147    fn default() -> Self {
148        RenderConfig {
149            size: SizeSpec::Scale(1.0),
150            format: PixelFormat::Bgra8,
151            background: Color::WHITE,
152            annotations: true,
153            form_fields: true,
154            extra_rotation: Rotation::None,
155            text_antialiasing: true,
156            image_antialiasing: true,
157            path_antialiasing: true,
158            max_output_bytes: RenderConfig::DEFAULT_MAX_OUTPUT_BYTES,
159        }
160    }
161}
162
163impl RenderConfig {
164    /// Default output-size ceiling: 1 GiB of pixel data.
165    pub const DEFAULT_MAX_OUTPUT_BYTES: u64 = 1 << 30;
166
167    /// A configuration rendering at 1:1 (72 DPI), BGRA, white background,
168    /// annotations and form fields on, 1 GiB output cap.
169    pub fn new() -> RenderConfig {
170        RenderConfig::default()
171    }
172
173    /// Scale factor over page points (2.0 renders a 612×792pt page at
174    /// 1224×1584px). Mutually exclusive with the other size selectors;
175    /// the last one set wins.
176    pub fn scale(mut self, factor: f32) -> Self {
177        self.size = SizeSpec::Scale(factor);
178        self
179    }
180
181    /// Resolution in dots per inch (72 DPI == scale 1.0).
182    pub fn dpi(mut self, dpi: f32) -> Self {
183        self.size = SizeSpec::Scale(dpi / 72.0);
184        self
185    }
186
187    /// Fixed output width in pixels, preserving aspect ratio.
188    pub fn width(mut self, pixels: u32) -> Self {
189        self.size = SizeSpec::Width(pixels);
190        self
191    }
192
193    /// Fixed output height in pixels, preserving aspect ratio.
194    pub fn height(mut self, pixels: u32) -> Self {
195        self.size = SizeSpec::Height(pixels);
196        self
197    }
198
199    /// Largest output that fits in `width`×`height`, preserving aspect
200    /// ratio.
201    pub fn fit(mut self, width: u32, height: u32) -> Self {
202        self.size = SizeSpec::Fit(width, height);
203        self
204    }
205
206    /// Exact output dimensions (may distort the aspect ratio).
207    pub fn exact(mut self, width: u32, height: u32) -> Self {
208        self.size = SizeSpec::Exact(width, height);
209        self
210    }
211
212    /// Output pixel format (default [`PixelFormat::Bgra8`]).
213    pub fn pixel_format(mut self, format: PixelFormat) -> Self {
214        self.format = format;
215        self
216    }
217
218    /// Background color the page is composited over (default white). Use
219    /// [`Color::TRANSPARENT`] with an alpha format for compositing.
220    pub fn background(mut self, color: Color) -> Self {
221        self.background = color;
222        self
223    }
224
225    /// Render annotations (default `true`).
226    pub fn annotations(mut self, on: bool) -> Self {
227        self.annotations = on;
228        self
229    }
230
231    /// Draw AcroForm field appearances (default `true`). Takes effect only
232    /// when the document has
233    /// [`enable_form_rendering`](crate::PdfDocument::enable_form_rendering)
234    /// active.
235    pub fn form_fields(mut self, on: bool) -> Self {
236        self.form_fields = on;
237        self
238    }
239
240    /// Extra rotation applied at render time, on top of the page's own
241    /// `/Rotate` (default none). 90°/270° swap the output dimensions.
242    pub fn rotate(mut self, rotation: Rotation) -> Self {
243        self.extra_rotation = rotation;
244        self
245    }
246
247    /// Toggle text anti-aliasing (default `true`).
248    pub fn text_antialiasing(mut self, on: bool) -> Self {
249        self.text_antialiasing = on;
250        self
251    }
252
253    /// Toggle image anti-aliasing (default `true`).
254    pub fn image_antialiasing(mut self, on: bool) -> Self {
255        self.image_antialiasing = on;
256        self
257    }
258
259    /// Toggle path anti-aliasing (default `true`).
260    pub fn path_antialiasing(mut self, on: bool) -> Self {
261        self.path_antialiasing = on;
262        self
263    }
264
265    /// Ceiling on the pixel buffer size in bytes
266    /// (default [`RenderConfig::DEFAULT_MAX_OUTPUT_BYTES`], 1 GiB).
267    /// Renders that would exceed it fail with [`Error::RenderTooLarge`]
268    /// *before* allocating. Use `u64::MAX` to disable.
269    pub fn max_output_bytes(mut self, limit: u64) -> Self {
270        self.max_output_bytes = limit;
271        self
272    }
273
274    /// The output dimensions this configuration produces for a page of the
275    /// given size (points, post-`/Rotate`).
276    pub(crate) fn resolve_dimensions(&self, page: crate::PageSize) -> Result<(u32, u32)> {
277        // Work in device orientation: extra 90°/270° rotation swaps axes.
278        let (pw, ph) = if self.extra_rotation.swaps_axes() {
279            (page.height as f64, page.width as f64)
280        } else {
281            (page.width as f64, page.height as f64)
282        };
283        if !(pw.is_finite() && ph.is_finite()) || pw <= 0.0 || ph <= 0.0 {
284            return Err(Error::InvalidConfig(format!(
285                "page has degenerate dimensions {pw}x{ph}pt"
286            )));
287        }
288
289        let scaled = |scale: f64| -> Result<(u32, u32)> {
290            if !scale.is_finite() || scale <= 0.0 {
291                return Err(Error::InvalidConfig(format!(
292                    "scale must be positive, got {scale}"
293                )));
294            }
295            Ok((
296                (pw * scale).round().max(1.0) as u32,
297                (ph * scale).round().max(1.0) as u32,
298            ))
299        };
300
301        let (w, h) = match self.size {
302            SizeSpec::Scale(s) => scaled(f64::from(s))?,
303            SizeSpec::Width(px) => {
304                nonzero(px, "width")?;
305                scaled(f64::from(px) / pw)?
306            }
307            SizeSpec::Height(px) => {
308                nonzero(px, "height")?;
309                scaled(f64::from(px) / ph)?
310            }
311            SizeSpec::Fit(bw, bh) => {
312                nonzero(bw, "fit width")?;
313                nonzero(bh, "fit height")?;
314                scaled((f64::from(bw) / pw).min(f64::from(bh) / ph))?
315            }
316            SizeSpec::Exact(w, h) => {
317                nonzero(w, "width")?;
318                nonzero(h, "height")?;
319                (w, h)
320            }
321        };
322
323        // PDFium's bitmap API takes i32 dimensions and strides. The byte
324        // requirement is computed in u128: the saturating f64->u32 casts
325        // above can leave w and h at u32::MAX, whose product times bpp
326        // overflows u64.
327        let bpp = u128::from(self.format.bytes_per_pixel() as u64);
328        let required = u128::from(w) * u128::from(h) * bpp;
329        let required_bytes = u64::try_from(required).unwrap_or(u64::MAX);
330        let too_large = w > i32::MAX as u32
331            || h > i32::MAX as u32
332            || u128::from(w) * bpp > i32::MAX as u128
333            || required > u128::from(self.max_output_bytes);
334        if too_large {
335            return Err(Error::RenderTooLarge {
336                required_bytes,
337                limit: self.max_output_bytes,
338            });
339        }
340        Ok((w, h))
341    }
342
343    fn flags(&self) -> c_int {
344        let mut flags = 0;
345        if self.annotations {
346            flags |= sys::FPDF_ANNOT;
347        }
348        if self.format == PixelFormat::Rgba8 {
349            flags |= sys::FPDF_REVERSE_BYTE_ORDER;
350        }
351        if !self.text_antialiasing {
352            flags |= sys::FPDF_RENDER_NO_SMOOTHTEXT;
353        }
354        if !self.image_antialiasing {
355            flags |= sys::FPDF_RENDER_NO_SMOOTHIMAGE;
356        }
357        if !self.path_antialiasing {
358            flags |= sys::FPDF_RENDER_NO_SMOOTHPATH;
359        }
360        flags
361    }
362}
363
364fn nonzero(v: u32, what: &str) -> Result<()> {
365    if v == 0 {
366        return Err(Error::InvalidConfig(format!("{what} must be nonzero")));
367    }
368    Ok(())
369}
370
371/// A rendered page: owned pixels plus everything needed to interpret them.
372///
373/// Contains **no PDFium resources** — it is plain data, freely `Send +
374/// Sync`, and remains valid after the page, document, and even the library
375/// handle are gone.
376#[derive(Debug, Clone)]
377pub struct RenderedPage {
378    width: u32,
379    height: u32,
380    stride: usize,
381    format: PixelFormat,
382    data: Vec<u8>,
383    page_index: usize,
384    transform: PageTransform,
385}
386
387impl RenderedPage {
388    /// Width in pixels.
389    pub fn width(&self) -> u32 {
390        self.width
391    }
392
393    /// Height in pixels.
394    pub fn height(&self) -> u32 {
395        self.height
396    }
397
398    /// Bytes per row. This crate always produces dense buffers
399    /// (`stride == width * bytes_per_pixel`), but consume [`stride`] rather
400    /// than assuming density.
401    ///
402    /// [`stride`]: Self::stride
403    pub fn stride(&self) -> usize {
404        self.stride
405    }
406
407    /// Pixel format of [`pixels`](Self::pixels).
408    pub fn format(&self) -> PixelFormat {
409        self.format
410    }
411
412    /// The raw pixel data, `height * stride` bytes, rows top to bottom.
413    pub fn pixels(&self) -> &[u8] {
414        &self.data
415    }
416
417    /// Consumes the render, returning the pixel buffer.
418    pub fn into_pixels(self) -> Vec<u8> {
419        self.data
420    }
421
422    /// One row of pixels.
423    ///
424    /// # Panics
425    ///
426    /// Panics if `y >= height`.
427    pub fn row(&self, y: u32) -> &[u8] {
428        assert!(
429            y < self.height,
430            "row {y} out of bounds (height {})",
431            self.height
432        );
433        let start = y as usize * self.stride;
434        &self.data[start..start + self.width as usize * self.format.bytes_per_pixel()]
435    }
436
437    /// The bytes of one pixel.
438    ///
439    /// # Panics
440    ///
441    /// Panics if out of bounds.
442    pub fn pixel(&self, x: u32, y: u32) -> &[u8] {
443        assert!(
444            x < self.width,
445            "column {x} out of bounds (width {})",
446            self.width
447        );
448        let bpp = self.format.bytes_per_pixel();
449        let row = self.row(y);
450        &row[x as usize * bpp..(x as usize + 1) * bpp]
451    }
452
453    /// 0-based index of the page this was rendered from.
454    pub fn page_index(&self) -> usize {
455        self.page_index
456    }
457
458    /// The pixel↔page-space transform for exactly this render geometry.
459    pub fn transform(&self) -> &PageTransform {
460        &self.transform
461    }
462
463    /// Converts to tightly packed RGBA8 (e.g. for `image::RgbaImage` or PNG
464    /// encoders), whatever the source format.
465    pub fn to_rgba8(&self) -> Vec<u8> {
466        let w = self.width as usize;
467        let h = self.height as usize;
468        let mut out = Vec::with_capacity(w * h * 4);
469        for y in 0..h {
470            let row = &self.data[y * self.stride..];
471            match self.format {
472                PixelFormat::Rgba8 => out.extend_from_slice(&row[..w * 4]),
473                PixelFormat::Bgra8 => {
474                    for px in row[..w * 4].chunks_exact(4) {
475                        out.extend_from_slice(&[px[2], px[1], px[0], px[3]]);
476                    }
477                }
478                PixelFormat::Bgr8 => {
479                    for px in row[..w * 3].chunks_exact(3) {
480                        out.extend_from_slice(&[px[2], px[1], px[0], 0xFF]);
481                    }
482                }
483                PixelFormat::Gray8 => {
484                    for &g in &row[..w] {
485                        out.extend_from_slice(&[g, g, g, 0xFF]);
486                    }
487                }
488            }
489        }
490        out
491    }
492}
493
494impl<'doc> PdfPage<'doc> {
495    /// Renders this page to an owned pixel buffer.
496    ///
497    /// The whole page is rendered (no partial viewports in this version);
498    /// resolution, format, background, rotation, and layer toggles come
499    /// from `config`.
500    pub fn render(&self, config: &RenderConfig) -> Result<RenderedPage> {
501        let (width, height) = config.resolve_dimensions(self.size())?;
502        let bpp = config.format.bytes_per_pixel();
503        let stride = width as usize * bpp;
504        let mut data = vec![0u8; stride * height as usize];
505        fill_background(&mut data, config.format, config.background);
506
507        let rotate = config.extra_rotation.as_raw();
508        let flags = config.flags();
509        let draw_forms = config.form_fields && self.document().form_env().is_some();
510
511        let transform = self.ffi(|b| -> Result<PageTransform> {
512            // SAFETY: dimensions/stride validated i32-safe by
513            // resolve_dimensions; `data` outlives the bitmap handle (both
514            // live in this frame, bitmap destroyed below); external-buffer
515            // mode means FPDFBitmap_Destroy will not free `data`.
516            let bitmap = unsafe {
517                b.FPDFBitmap_CreateEx(
518                    width as c_int,
519                    height as c_int,
520                    config.format.as_fpdf(),
521                    data.as_mut_ptr().cast(),
522                    stride as c_int,
523                )
524            };
525            if bitmap.is_null() {
526                return Err(Error::RenderFailed {
527                    reason: "FPDFBitmap_CreateEx returned null",
528                });
529            }
530
531            // SAFETY: live bitmap + page handles; geometry matches the
532            // bitmap dimensions exactly.
533            unsafe {
534                b.FPDF_RenderPageBitmap(
535                    bitmap,
536                    self.handle(),
537                    0,
538                    0,
539                    width as c_int,
540                    height as c_int,
541                    rotate,
542                    flags,
543                );
544            }
545
546            if draw_forms {
547                if let Some(env) = self.document().form_env() {
548                    // SAFETY: live handles; header: call FPDF_FFLDraw
549                    // "after rendering functions ... have finished
550                    // rendering the page contents", same geometry.
551                    unsafe {
552                        b.FPDF_FFLDraw(
553                            env.handle(),
554                            bitmap,
555                            self.handle(),
556                            0,
557                            0,
558                            width as c_int,
559                            height as c_int,
560                            rotate,
561                            flags,
562                        );
563                    }
564                }
565            }
566
567            // SAFETY: live bitmap handle, destroyed exactly once. The pixel
568            // buffer is ours and survives.
569            unsafe { b.FPDFBitmap_Destroy(bitmap) };
570
571            derive_transform(b, self.handle(), width, height, rotate)
572        })?;
573
574        Ok(RenderedPage {
575            width,
576            height,
577            stride,
578            format: config.format,
579            data,
580            page_index: self.index(),
581            transform,
582        })
583    }
584
585    /// Computes the pixel↔page transform for `config` **without
586    /// rendering** — useful to plan layouts or map coordinates for a render
587    /// that will happen elsewhere.
588    pub fn transform_for(&self, config: &RenderConfig) -> Result<PageTransform> {
589        let (width, height) = config.resolve_dimensions(self.size())?;
590        let rotate = config.extra_rotation.as_raw();
591        self.ffi(|b| derive_transform(b, self.handle(), width, height, rotate))
592    }
593
594    /// PDFium's own device→page conversion (`FPDF_DeviceToPage`) for the
595    /// geometry `config` would produce.
596    ///
597    /// PDFium's C API takes *integer* device coordinates, so `pixel` is
598    /// rounded to the nearest pixel first. [`PageTransform`] (from
599    /// [`transform_for`](Self::transform_for) or a render) offers the same
600    /// mapping in continuous coordinates without an FFI call; the two agree
601    /// to within the integer quantization (tested).
602    pub fn device_to_page(
603        &self,
604        config: &RenderConfig,
605        pixel: crate::PixelPoint,
606    ) -> Result<crate::PagePoint> {
607        let (width, height) = config.resolve_dimensions(self.size())?;
608        let rotate = config.extra_rotation.as_raw();
609        let dx = clamp_to_c_int(pixel.x)?;
610        let dy = clamp_to_c_int(pixel.y)?;
611        let (mut px, mut py) = (0.0f64, 0.0f64);
612        // SAFETY: live page handle, valid out-pointers; viewport parameters
613        // match what a render with this config would use, as PDFium
614        // requires.
615        let ok = self.ffi(|b| unsafe {
616            b.FPDF_DeviceToPage(
617                self.handle(),
618                0,
619                0,
620                width as c_int,
621                height as c_int,
622                rotate,
623                dx,
624                dy,
625                &mut px,
626                &mut py,
627            )
628        });
629        if ok != 0 {
630            Ok(crate::PagePoint::new(px, py))
631        } else {
632            Err(Error::RenderFailed {
633                reason: "FPDF_DeviceToPage failed",
634            })
635        }
636    }
637
638    /// PDFium's own page→device conversion (`FPDF_PageToDevice`) for the
639    /// geometry `config` would produce.
640    ///
641    /// PDFium returns *integer* device coordinates, so the result is
642    /// quantized to whole pixels. Use [`PageTransform`] for sub-pixel
643    /// precision; the two agree to within one pixel (tested).
644    pub fn page_to_device(
645        &self,
646        config: &RenderConfig,
647        point: crate::PagePoint,
648    ) -> Result<crate::PixelPoint> {
649        let (width, height) = config.resolve_dimensions(self.size())?;
650        let rotate = config.extra_rotation.as_raw();
651        let (mut dx, mut dy) = (0 as c_int, 0 as c_int);
652        // SAFETY: live page handle, valid out-pointers; viewport parameters
653        // match what a render with this config would use.
654        let ok = self.ffi(|b| unsafe {
655            b.FPDF_PageToDevice(
656                self.handle(),
657                0,
658                0,
659                width as c_int,
660                height as c_int,
661                rotate,
662                point.x,
663                point.y,
664                &mut dx,
665                &mut dy,
666            )
667        });
668        if ok != 0 {
669            Ok(crate::PixelPoint::new(f64::from(dx), f64::from(dy)))
670        } else {
671            Err(Error::RenderFailed {
672                reason: "FPDF_PageToDevice failed",
673            })
674        }
675    }
676}
677
678fn clamp_to_c_int(v: f64) -> Result<c_int> {
679    let r = v.round();
680    if r.is_finite() && (f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&r) {
681        Ok(r as c_int)
682    } else {
683        Err(Error::InvalidConfig(format!(
684            "device coordinate {v} is outside the addressable integer range"
685        )))
686    }
687}
688
689/// Derives the affine pixel↔page transform by asking PDFium for the page
690/// coordinates of three exact integer device corners, using the same
691/// viewport parameters as the render (a documented requirement of
692/// `FPDF_DeviceToPage`).
693fn derive_transform(
694    b: &sys::Bindings,
695    page: sys::FPDF_PAGE,
696    width: u32,
697    height: u32,
698    rotate: c_int,
699) -> Result<PageTransform> {
700    let corner = |dx: c_int, dy: c_int| -> Result<(f64, f64)> {
701        let (mut px, mut py) = (0.0f64, 0.0f64);
702        // SAFETY: live page handle, valid out-pointers, geometry matches
703        // the associated render call.
704        let ok = unsafe {
705            b.FPDF_DeviceToPage(
706                page,
707                0,
708                0,
709                width as c_int,
710                height as c_int,
711                rotate,
712                dx,
713                dy,
714                &mut px,
715                &mut py,
716            )
717        };
718        if ok != 0 {
719            Ok((px, py))
720        } else {
721            Err(Error::RenderFailed {
722                reason: "FPDF_DeviceToPage failed",
723            })
724        }
725    };
726
727    let origin = corner(0, 0)?;
728    let x_axis = corner(width as c_int, 0)?;
729    let y_axis = corner(0, height as c_int)?;
730    PageTransform::from_corners(width, height, origin, x_axis, y_axis).ok_or(Error::RenderFailed {
731        reason: "degenerate page transform",
732    })
733}
734
735/// Pre-fills the buffer with the background color encoded for `format`.
736/// (PDFium composites page content over existing buffer contents; its own
737/// FillRect *replaces* pixels rather than compositing, so filling ourselves
738/// is both simpler and exact for every format including reversed byte
739/// order.)
740fn fill_background(data: &mut [u8], format: PixelFormat, color: Color) {
741    let (pattern, bpp) = color.encode(format);
742    let pattern = &pattern[..bpp];
743    if pattern.iter().all(|&b| b == pattern[0]) {
744        data.fill(pattern[0]);
745    } else {
746        for px in data.chunks_exact_mut(bpp) {
747            px.copy_from_slice(pattern);
748        }
749    }
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755    use crate::page::PageSize;
756
757    fn dims(cfg: &RenderConfig, w: f32, h: f32) -> Result<(u32, u32)> {
758        cfg.resolve_dimensions(PageSize {
759            width: w,
760            height: h,
761        })
762    }
763
764    #[test]
765    fn scale_and_dpi() {
766        assert_eq!(
767            dims(&RenderConfig::new().scale(2.0), 200.0, 100.0).unwrap(),
768            (400, 200)
769        );
770        assert_eq!(
771            dims(&RenderConfig::new().dpi(144.0), 200.0, 100.0).unwrap(),
772            (400, 200)
773        );
774    }
775
776    #[test]
777    fn fixed_axes_preserve_aspect() {
778        assert_eq!(
779            dims(&RenderConfig::new().width(400), 200.0, 100.0).unwrap(),
780            (400, 200)
781        );
782        assert_eq!(
783            dims(&RenderConfig::new().height(50), 200.0, 100.0).unwrap(),
784            (100, 50)
785        );
786        assert_eq!(
787            dims(&RenderConfig::new().fit(1000, 300), 200.0, 100.0).unwrap(),
788            (600, 300)
789        );
790        assert_eq!(
791            dims(&RenderConfig::new().exact(37, 91), 200.0, 100.0).unwrap(),
792            (37, 91)
793        );
794    }
795
796    #[test]
797    fn rotation_swaps_output_axes() {
798        let cfg = RenderConfig::new().scale(1.0).rotate(Rotation::Clockwise90);
799        assert_eq!(dims(&cfg, 200.0, 100.0).unwrap(), (100, 200));
800        // width() means *output* width, post-rotation.
801        let cfg = RenderConfig::new().width(300).rotate(Rotation::Clockwise90);
802        assert_eq!(dims(&cfg, 200.0, 100.0).unwrap(), (300, 600));
803    }
804
805    #[test]
806    fn size_cap_enforced() {
807        let cfg = RenderConfig::new().scale(100.0).max_output_bytes(1024);
808        match dims(&cfg, 200.0, 100.0) {
809            Err(Error::RenderTooLarge {
810                required_bytes,
811                limit,
812            }) => {
813                assert_eq!(limit, 1024);
814                assert!(required_bytes > 1024);
815            }
816            other => panic!("expected RenderTooLarge, got {other:?}"),
817        }
818    }
819
820    #[test]
821    fn invalid_inputs_rejected() {
822        assert!(matches!(
823            dims(&RenderConfig::new().scale(0.0), 200.0, 100.0),
824            Err(Error::InvalidConfig(_))
825        ));
826        assert!(matches!(
827            dims(&RenderConfig::new().scale(f32::NAN), 200.0, 100.0),
828            Err(Error::InvalidConfig(_))
829        ));
830        assert!(matches!(
831            dims(&RenderConfig::new().exact(0, 10), 200.0, 100.0),
832            Err(Error::InvalidConfig(_))
833        ));
834    }
835
836    #[test]
837    fn background_fill_patterns() {
838        let mut buf = vec![0u8; 12];
839        fill_background(&mut buf, PixelFormat::Bgra8, Color::rgba(1, 2, 3, 4));
840        assert_eq!(&buf[..4], &[3, 2, 1, 4]);
841        fill_background(&mut buf, PixelFormat::Rgba8, Color::rgba(1, 2, 3, 4));
842        assert_eq!(&buf[..4], &[1, 2, 3, 4]);
843        let mut buf3 = vec![0u8; 9];
844        fill_background(&mut buf3, PixelFormat::Bgr8, Color::rgb(10, 20, 30));
845        assert_eq!(&buf3[..3], &[30, 20, 10]);
846    }
847}