Skip to main content

pdf_render/
lib.rs

1/*!
2A crate for rendering PDF files.
3
4This crate allows you to render pages of a PDF file into bitmaps. It is supposed to be relatively
5lightweight, since we do not have any dependencies on the GPU. All the rendering happens on the CPU.
6
7The ultimate goal of this crate is to be a *feature-complete* and *performant* PDF rasterizer.
8With that said, we are currently still very far away from reaching that goal: So far, no effort
9has been put into performance optimizations, as we are still working on implementing missing features.
10However, this crate is currently the most comprehensive and feature-complete
11implementation of a PDF rasterizer in pure Rust. This claim is supported by the fact that we currently
12include over 1000 PDF files in our regression test suite. The majority of those have been scraped
13from the `pdf.js` and `PDFBOX` test suites and therefore represent a very large and diverse sample
14of PDF files.
15
16As mentioned, there are still some serious limitations, including lack of support for
17encrypted/password-protected PDF files, blending and isolation, knockout groups as well as a range
18of smaller features such as color key masking. But you should be able to render the vast majority
19of PDF files without too many issues.
20
21## Safety
22This crate forbids unsafe code via a crate-level attribute.
23
24## Examples
25For usage examples, see the [example](https://github.com/LaurenzV/hayro/tree/master/hayro/examples) in
26the GitHub repository.
27
28## Cargo features
29This crate has one optional feature:
30- `embed-fonts`: See the description of [`pdf-interpret`](https://docs.rs/pdf-interpret/latest/pdf_interpret/#cargo-features) for more information.
31*/
32
33#![forbid(unsafe_code)]
34#![deny(missing_docs)]
35
36use crate::renderer::Renderer;
37use kurbo::{Affine, Rect, Shape};
38use pdf_interpret::Device;
39use pdf_interpret::FillRule;
40use pdf_interpret::InterpreterSettings;
41use pdf_interpret::pdf_syntax::Pdf;
42use pdf_interpret::pdf_syntax::page::Page;
43use pdf_interpret::util::PageExt;
44use pdf_interpret::{BlendMode, Context};
45use pdf_interpret::{ClipPath, interpret_page};
46use std::ops::RangeInclusive;
47
48/// Whether per-stage render tracing is enabled (env `PDF_RENDER_TRACE=1`).
49/// Read once; zero cost in the hot path when disabled.
50fn render_trace_enabled() -> bool {
51    use std::sync::OnceLock;
52    static ENABLED: OnceLock<bool> = OnceLock::new();
53    *ENABLED.get_or_init(|| {
54        std::env::var("PDF_RENDER_TRACE")
55            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
56            .unwrap_or(false)
57    })
58}
59
60/// Worker-thread count for vello_cpu rasterization. Only has an effect on native
61/// targets where the `multithreading` feature is enabled (wasm32 keeps the
62/// single-threaded path). `0` = single-threaded. Multi-threaded tiled raster is
63/// deterministic and byte-identical to single-threaded (verified by test).
64///
65/// Default: `available_parallelism` on native; overridable via the
66/// `PDF_RENDER_THREADS` env var. Both `0` and `1` select the single-threaded
67/// path (`0`): vello takes the multi-threaded path for any nonzero count, so
68/// `1` would still build its own pool and wait on it, for no gain.
69///
70/// A page rendered from inside a rayon worker always uses `0`: the outer pool
71/// is already parallel, so nothing is lost, and dispatching to vello's own
72/// pool from a worker that then waits lets rayon steal further queued page
73/// jobs onto the same stack, which grows with every queued page until the
74/// process aborts (#636).
75fn render_num_threads() -> u16 {
76    // Not cached: the same process renders both inside and outside pools, and
77    // only the configured base below is stable across calls.
78    #[cfg(not(target_arch = "wasm32"))]
79    if rayon::current_thread_index().is_some() {
80        return 0;
81    }
82    use std::sync::OnceLock;
83    static N: OnceLock<u16> = OnceLock::new();
84    *N.get_or_init(configured_num_threads)
85}
86
87/// Configured base thread count: `PDF_RENDER_THREADS` when set and parseable,
88/// else the machine default. Both `0` and `1` mean single-threaded (`0`).
89fn configured_num_threads() -> u16 {
90    configured_num_threads_from_env(std::env::var("PDF_RENDER_THREADS").ok().as_deref())
91}
92
93/// Same, with the raw variable value injected so tests can pin the mapping
94/// without touching process state.
95fn configured_num_threads_from_env(raw: Option<&str>) -> u16 {
96    if let Some(n) = raw.and_then(|v| v.parse::<u16>().ok()) {
97        return normalize_num_threads(n);
98    }
99    #[cfg(not(target_arch = "wasm32"))]
100    {
101        normalize_num_threads(
102            std::thread::available_parallelism()
103                .map(|n| n.get().min(u16::MAX as usize) as u16)
104                .unwrap_or(1),
105        )
106    }
107    #[cfg(target_arch = "wasm32")]
108    {
109        0
110    }
111}
112
113/// Map a raw thread count onto vello's two paths: `0` and `1` both select the
114/// single-threaded path (`0`); anything larger passes through.
115fn normalize_num_threads(n: u16) -> u16 {
116    if n <= 1 { 0 } else { n }
117}
118
119pub use glyph_cache::GlyphOutlineCache;
120pub use pdf_interpret;
121pub use pdf_interpret::pdf_syntax;
122pub use vello_cpu;
123
124use vello_cpu::color::AlphaColor;
125use vello_cpu::color::Srgb;
126use vello_cpu::color::palette::css::TRANSPARENT;
127use vello_cpu::color::palette::css::WHITE;
128use vello_cpu::{Level, Pixmap, RenderMode};
129
130mod glyph_cache;
131mod renderer;
132
133/// Rasterization precision / speed trade-off for the vello_cpu pipeline.
134///
135/// vello_cpu ships two compositing pipelines: a higher-precision `f32` pipeline
136/// and a faster `u8` pipeline. Both are compiled in; this selects which one a
137/// given render uses.
138///
139/// The default is [`RasterQuality::Quality`] (the `f32` pipeline), which keeps
140/// output **byte-identical** to historical PDFluent releases. [`RasterQuality::Speed`]
141/// is an explicit, caller-controlled opt-in: on content-heavy pages it renders
142/// ~1.4–1.6× faster, at the cost of sub-perceptual rounding differences wherever
143/// alpha blending, anti-aliasing or images compose (8-bit vs f32 compositing
144/// precision). Pages built only from opaque vector fills are byte-identical in
145/// both modes.
146#[derive(Clone, Copy, Debug, PartialEq, Eq)]
147pub enum RasterQuality {
148    /// Higher-precision `f32` compositing pipeline. Default; matches historical
149    /// output byte-for-byte.
150    Quality,
151    /// Faster `u8` compositing pipeline (~1.4–1.6× on content-heavy pages).
152    /// Opt-in; output differs from [`RasterQuality::Quality`] by sub-perceptual
153    /// rounding where blending/AA/images compose.
154    Speed,
155}
156
157/// The default raster mode everywhere: engine `RenderOptions`, facade
158/// `ToImagesOptions`/`RenderPageOptions`, and the CLI when `--raster` is
159/// absent all resolve through [`RasterQuality::default`], which returns this.
160///
161/// Do not change here casually: flipping to [`RasterQuality::Speed`] is the
162/// one-line follow-up gated on the T5 measurement (SSIM ≥ 0.999 vs current
163/// output on every page).
164pub const DEFAULT_RASTER_QUALITY: RasterQuality = RasterQuality::Quality;
165
166impl Default for RasterQuality {
167    fn default() -> Self {
168        DEFAULT_RASTER_QUALITY
169    }
170}
171
172impl RasterQuality {
173    /// Map to the underlying vello_cpu render mode.
174    fn render_mode(self) -> RenderMode {
175        match self {
176            // OptimizeQuality requires the `f32_pipeline` feature (enabled in Cargo.toml).
177            RasterQuality::Quality => RenderMode::OptimizeQuality,
178            RasterQuality::Speed => RenderMode::OptimizeSpeed,
179        }
180    }
181}
182
183/// Settings to apply during rendering.
184#[derive(Clone, Copy)]
185pub struct RenderSettings {
186    /// How much the contents should be scaled into the x direction.
187    pub x_scale: f32,
188    /// How much the contents should be scaled into the y direction.
189    pub y_scale: f32,
190    /// The width of the viewport. If this is set to `None`, the width will be chosen
191    /// automatically based on the scale factor and the dimensions of the PDF.
192    pub width: Option<u16>,
193    /// The height of the viewport. If this is set to `None`, the height will be chosen
194    /// automatically based on the scale factor and the dimensions of the PDF.
195    pub height: Option<u16>,
196    /// The background color. Determines the color of the base
197    /// rectangle during rendering to a pixmap.
198    pub bg_color: AlphaColor<Srgb>,
199    /// Rasterization precision/speed trade-off (default [`RasterQuality::Quality`],
200    /// which is byte-identical to historical output).
201    pub quality: RasterQuality,
202}
203
204impl Default for RenderSettings {
205    fn default() -> Self {
206        Self {
207            x_scale: 1.0,
208            y_scale: 1.0,
209            width: None,
210            height: None,
211            bg_color: TRANSPARENT,
212            quality: RasterQuality::default(),
213        }
214    }
215}
216
217/// Render the page with the given settings to a pixmap.
218///
219/// Uses a fresh per-page glyph outline cache; to share outlines across the
220/// pages of a document, see [`render_with_glyph_cache`]. Output is identical
221/// either way.
222pub fn render(
223    page: &Page<'_>,
224    interpreter_settings: &InterpreterSettings,
225    render_settings: &RenderSettings,
226) -> Pixmap {
227    render_with_glyph_cache(
228        page,
229        interpreter_settings,
230        render_settings,
231        &mut GlyphOutlineCache::new(),
232    )
233}
234
235/// Render the page with the given settings to a pixmap, reusing (and
236/// warming) a caller-owned [`GlyphOutlineCache`].
237///
238/// Pass the same cache for every page of a document to build each distinct
239/// glyph outline once per document instead of once per page. The cache only
240/// avoids rebuilds — the same path in the same order under the same
241/// transform — so output is byte-identical to [`render`], and rendering
242/// stays pure per page. [`GlyphOutlineCache::hits`] and
243/// [`GlyphOutlineCache::misses`] report how the cache served the render.
244///
245/// Pattern tiles and soft masks render into private bounded caches; only the
246/// page's own glyphs go through `glyph_cache`.
247pub fn render_with_glyph_cache(
248    page: &Page<'_>,
249    interpreter_settings: &InterpreterSettings,
250    render_settings: &RenderSettings,
251    glyph_cache: &mut GlyphOutlineCache,
252) -> Pixmap {
253    let (x_scale, y_scale) = (render_settings.x_scale, render_settings.y_scale);
254    let (width, height) = page.render_dimensions();
255    let (scaled_width, scaled_height) = ((width * x_scale) as f64, (height * y_scale) as f64);
256    let initial_transform =
257        Affine::scale_non_uniform(x_scale as f64, y_scale as f64) * page.initial_transform(true);
258
259    // Clamp to at least 1 pixel. Pages with zero-area MediaBox (e.g. adversarial
260    // PDFs from the poppler fuzzing corpus) produce scaled_width/height = 0.
261    // vello_common::Pixmap::new(0, 0) allocates an empty buffer; any subsequent
262    // pixel sample then panics with "index out of bounds: the len is 0".
263    // Fixes crashes on poppler-327-0.zip-{0,1}.pdf. (#546)
264    // Round-half-up (PDFium convention: (int)(size*scale + 0.5)) rather than
265    // ceil. PDFium / pdfRest is our AVRT oracle; ceil produced a 0–1 px
266    // height/width excess vs pdfrest on 9 non-integer-MediaBox PDFs (e.g.
267    // 0273, 0139, 0356, 0368, 0508, 0272, 0568, 0418, 0325), destroying SSIM
268    // through pixel-row/column misalignment.
269    // For exact integer values round/ceil/floor are identical. (#1001, #544, #558)
270    let (pix_width, pix_height) = (
271        render_settings
272            .width
273            .unwrap_or(scaled_width.round() as u16)
274            .max(1),
275        render_settings
276            .height
277            .unwrap_or(scaled_height.round() as u16)
278            .max(1),
279    );
280    let trace = render_trace_enabled();
281    let t_setup = trace.then(std::time::Instant::now);
282    let mut state = Context::new(
283        initial_transform,
284        Rect::new(0.0, 0.0, pix_width as f64, pix_height as f64),
285        page.xref(),
286        interpreter_settings.clone(),
287    );
288
289    let vc_settings = vello_cpu::RenderSettings {
290        level: Level::new(),
291        num_threads: render_num_threads(),
292        render_mode: render_settings.quality.render_mode(),
293    };
294
295    let mut device = Renderer::new(pix_width, pix_height, vc_settings);
296    device.set_glyph_cache(std::mem::take(glyph_cache));
297
298    device.ctx.set_paint(render_settings.bg_color);
299    device
300        .ctx
301        .fill_rect(&Rect::new(0.0, 0.0, pix_width as f64, pix_height as f64));
302    // Clip to the canvas bounds (integer pixel dimensions) rather than the
303    // sub-pixel-precise transformed CropBox rectangle.
304    // MuPDF clips to the integer pixel canvas boundary (ceil(crop_box × scale));
305    // it does not impose a separate sub-pixel-accurate CropBox clip.  Using the
306    // exact transformed CropBox rect causes anti-aliased edge columns/rows that
307    // differ from MuPDF at the sub-pixel boundary (e.g. a 25 pt page at 150 DPI
308    // = 52.083 px → the last pixel column ends up near-white in our render but
309    // fully-painted dark red in MuPDF).  Clipping to the integer canvas bounds
310    // reproduces MuPDF's behaviour while still preventing content from bleeding
311    // outside the canvas.  For the case where CropBox extends beyond MediaBox
312    // (gen-802), content outside the MediaBox is simply unpainted (background
313    // colour), so no visible difference results.  (#558, follow-up to #544)
314    device.push_clip_path(&ClipPath {
315        path: Rect::new(0.0, 0.0, pix_width as f64, pix_height as f64).to_path(0.1),
316        fill: FillRule::NonZero,
317    });
318
319    device.push_transparency_group(1.0, None, BlendMode::Normal);
320
321    // Stage timing (env-gated; zero cost when disabled): the two dominant phases
322    // are (1) `interpret_page` — building the vello scene/display list from the
323    // PDF content stream (path/text/image construction), and (2)
324    // `render_to_pixmap` — vello_cpu rasterization to RGBA. This split localizes
325    // whether render cost is scene-build or rasterization.
326    // Setup = Context/Renderer construction + background fill + clip/group push,
327    // measured up to (but excluding) interpretation.
328    let setup_ms = t_setup.map(|t| t.elapsed().as_secs_f64() * 1000.0);
329    let t_interpret = trace.then(std::time::Instant::now);
330    interpret_page(page, &mut state, &mut device);
331    let interpret_ms = t_interpret.map(|t| t.elapsed().as_secs_f64() * 1000.0);
332
333    device.pop_transparency_group();
334
335    device.pop_clip_path();
336
337    let mut pixmap = Pixmap::new(pix_width, pix_height);
338    let t_raster = trace.then(std::time::Instant::now);
339    // Multi-threaded rasterization requires an explicit flush before sampling
340    // the pixmap; on the single-threaded path flush() is a no-op.
341    device.ctx.flush();
342    device.ctx.render_to_pixmap(&mut pixmap);
343    let raster_ms = t_raster.map(|t| t.elapsed().as_secs_f64() * 1000.0);
344
345    if trace {
346        eprintln!(
347            "PDF_RENDER_TRACE setup_ms={:.3} interpret_ms={:.2} raster_ms={:.2} w={} h={} threads={}",
348            setup_ms.unwrap_or(0.0),
349            interpret_ms.unwrap_or(0.0),
350            raster_ms.unwrap_or(0.0),
351            pix_width,
352            pix_height,
353            vc_settings.num_threads,
354        );
355    }
356
357    *glyph_cache = device.take_glyph_cache();
358
359    pixmap
360}
361
362// Just a convenience method for testing.
363#[doc(hidden)]
364pub fn render_pdf(
365    pdf: &Pdf,
366    scale: f32,
367    settings: InterpreterSettings,
368    range: Option<RangeInclusive<usize>>,
369) -> Option<Vec<Pixmap>> {
370    let rendered = pdf
371        .pages()
372        .iter()
373        .enumerate()
374        .flat_map(|(idx, page)| {
375            if range.clone().is_some_and(|range| !range.contains(&idx)) {
376                return None;
377            }
378
379            let pixmap = render(
380                page,
381                &settings,
382                &RenderSettings {
383                    x_scale: scale,
384                    y_scale: scale,
385                    bg_color: WHITE,
386                    ..Default::default()
387                },
388            );
389
390            Some(pixmap)
391        })
392        .collect();
393
394    Some(rendered)
395}
396
397pub(crate) fn derive_settings(settings: &vello_cpu::RenderSettings) -> vello_cpu::RenderSettings {
398    vello_cpu::RenderSettings {
399        num_threads: 0,
400        ..*settings
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use pdf_interpret::InterpreterSettings;
408    use pdf_syntax::Pdf;
409
410    /// Build a minimal one-page PDF (72×72 pt empty page) using lopdf.
411    fn minimal_pdf_bytes() -> Vec<u8> {
412        use lopdf::{Document, Object, Stream, dictionary};
413
414        let mut doc = Document::with_version("1.4");
415
416        let pages_id = doc.new_object_id();
417        let page_id = doc.new_object_id();
418
419        // Empty content stream so the page has a valid structure.
420        let content = Stream::new(dictionary! {}, b"".to_vec());
421        let content_id = doc.add_object(content);
422
423        doc.objects.insert(
424            page_id,
425            Object::Dictionary(dictionary! {
426                "Type"      => Object::Name(b"Page".to_vec()),
427                "Parent"    => Object::Reference(pages_id),
428                "MediaBox"  => Object::Array(vec![
429                    Object::Integer(0), Object::Integer(0),
430                    Object::Integer(72), Object::Integer(72),
431                ]),
432                "Contents"  => Object::Reference(content_id),
433            }),
434        );
435
436        doc.objects.insert(
437            pages_id,
438            Object::Dictionary(dictionary! {
439                "Type"  => Object::Name(b"Pages".to_vec()),
440                "Kids"  => Object::Array(vec![Object::Reference(page_id)]),
441                "Count" => Object::Integer(1),
442            }),
443        );
444
445        let catalog_id = doc.new_object_id();
446        doc.objects.insert(
447            catalog_id,
448            Object::Dictionary(dictionary! {
449                "Type"  => Object::Name(b"Catalog".to_vec()),
450                "Pages" => Object::Reference(pages_id),
451            }),
452        );
453
454        doc.trailer.set("Root", Object::Reference(catalog_id));
455
456        let mut bytes = Vec::new();
457        doc.save_to(&mut bytes).expect("lopdf save should succeed");
458        bytes
459    }
460
461    #[test]
462    fn render_pdf_returns_one_pixmap() {
463        let bytes = minimal_pdf_bytes();
464        let pdf = Pdf::new(bytes).expect("PDF should load");
465        let pixmaps = render_pdf(&pdf, 1.0, InterpreterSettings::default(), None);
466        assert!(pixmaps.is_some());
467        assert_eq!(pixmaps.unwrap().len(), 1);
468    }
469
470    /// Assemble a PDF from numbered object bodies (index `i` becomes object
471    /// `i + 1`), computing byte-accurate xref offsets.
472    fn raw_pdf_bytes(objects: &[Vec<u8>]) -> Vec<u8> {
473        let mut out = b"%PDF-1.7\n".to_vec();
474        let mut offsets = Vec::with_capacity(objects.len());
475
476        for (i, body) in objects.iter().enumerate() {
477            offsets.push(out.len());
478            out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
479            out.extend_from_slice(body);
480            out.extend_from_slice(b"\nendobj\n");
481        }
482
483        let xref_pos = out.len();
484        out.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
485        out.extend_from_slice(b"0000000000 65535 f \n");
486        for offset in offsets {
487            out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
488        }
489        out.extend_from_slice(
490            format!(
491                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n",
492                objects.len() + 1
493            )
494            .as_bytes(),
495        );
496
497        out
498    }
499
500    /// LaurenzV/hayro 128996e9 (#1285): a downscaled image with a soft mask
501    /// must be filtered premultiplied, or semi-transparent edges fringe.
502    /// The fixture is a 2x1 image — red opaque next to transparent black —
503    /// drawn over a 2x1 pt page and rendered at half scale, so the RGBA
504    /// resize arm blends the two pixels into one. Premultiplied (correct),
505    /// the blend is red at half alpha, which composites to pinkish-white
506    /// over the white background; naive straight-alpha filtering fringes
507    /// the red channel down to ~191.
508    ///
509    /// Our resizer (fast_image_resize, `mul_div_alpha` default on) already
510    /// multiplies before convolution and divides after, so the defect never
511    /// reached this tree; this test pins the behavior rather than a code
512    /// change. It fails if the resize ever filters straight alpha (e.g.
513    /// with `use_alpha(false)`).
514    #[test]
515    fn downscaled_smasked_image_composites_without_fringe() {
516        let image = [
517            b"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 \
518              /ColorSpace /DeviceRGB /SMask 5 0 R /Length 6 >>\nstream\n"
519                .as_slice(),
520            [255, 0, 0, 0, 0, 0].as_slice(),
521            b"\nendstream".as_slice(),
522        ]
523        .concat();
524        let smask = [
525            b"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 \
526              /ColorSpace /DeviceGray /Length 2 >>\nstream\n"
527                .as_slice(),
528            [255, 0].as_slice(),
529            b"\nendstream".as_slice(),
530        ]
531        .concat();
532        let content = b"<< /Length 22 >>\nstream\n2 0 0 1 0 0 cm /Im0 Do\nendstream".to_vec();
533
534        let bytes = raw_pdf_bytes(&[
535            b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
536            b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
537            b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 2 1] \
538              /Resources << /XObject << /Im0 4 0 R >> >> /Contents 6 0 R >>"
539                .to_vec(),
540            image,
541            smask,
542            content,
543        ]);
544
545        let pdf = Pdf::new(bytes).expect("PDF should load");
546        let pixmaps = render_pdf(&pdf, 0.5, InterpreterSettings::default(), None).unwrap();
547        assert_eq!(pixmaps.len(), 1);
548        let pixmap = &pixmaps[0];
549        assert_eq!((pixmap.width(), pixmap.height()), (1, 1));
550
551        // Red at half alpha over white: full red, half green/blue. A naive
552        // straight-alpha resize would fringe red down to ~191; an undrawn
553        // image would leave all-white (green 255).
554        let px = pixmap.data_as_u8_slice();
555        assert_eq!(px.len(), 4);
556        assert!(
557            px[0] > 240 && (100..160).contains(&px[1]) && (100..160).contains(&px[2]),
558            "expected pinkish-white, got {px:?}"
559        );
560    }
561
562    #[test]
563    fn render_pdf_pixmap_matches_mediabox() {
564        let bytes = minimal_pdf_bytes();
565        let pdf = Pdf::new(bytes).expect("PDF should load");
566        let pixmaps = render_pdf(&pdf, 1.0, InterpreterSettings::default(), None).unwrap();
567        let pixmap = &pixmaps[0];
568        // MediaBox is [0 0 72 72] → 72×72 pixels at scale 1.0.
569        assert_eq!(pixmap.width(), 72);
570        assert_eq!(pixmap.height(), 72);
571    }
572
573    #[test]
574    fn render_pdf_with_scale_2_doubles_dimensions() {
575        let bytes = minimal_pdf_bytes();
576        let pdf = Pdf::new(bytes).expect("PDF should load");
577        let pixmaps = render_pdf(&pdf, 2.0, InterpreterSettings::default(), None).unwrap();
578        let pixmap = &pixmaps[0];
579        assert_eq!(pixmap.width(), 144);
580        assert_eq!(pixmap.height(), 144);
581    }
582
583    #[test]
584    fn render_pdf_page_range_selects_single_page() {
585        let bytes = minimal_pdf_bytes();
586        let pdf = Pdf::new(bytes).expect("PDF should load");
587        // Range 0..=0 selects only the first (and only) page.
588        let pixmaps = render_pdf(&pdf, 1.0, InterpreterSettings::default(), Some(0..=0)).unwrap();
589        assert_eq!(pixmaps.len(), 1);
590    }
591
592    /// Rasterization must be deterministic and byte-identical across renders,
593    /// including under the multi-threaded vello_cpu path (native). This guards
594    /// the multithreading-enable change against any nondeterminism regression —
595    /// a pixel difference here would be a fidelity regression, not a perf win.
596    #[test]
597    fn render_pdf_is_byte_deterministic() {
598        let bytes = minimal_pdf_bytes();
599        let pdf = Pdf::new(bytes).expect("PDF should load");
600        let a = render_pdf(&pdf, 2.0, InterpreterSettings::default(), None).unwrap();
601        let b = render_pdf(&pdf, 2.0, InterpreterSettings::default(), None).unwrap();
602        assert_eq!(a.len(), b.len());
603        assert_eq!(
604            a[0].data_as_u8_slice(),
605            b[0].data_as_u8_slice(),
606            "render output must be byte-identical across runs"
607        );
608    }
609
610    /// Each `RasterQuality` mode must itself be deterministic (byte-identical
611    /// across runs) and produce identical dimensions. This guards the opt-in
612    /// Speed (u8) pipeline against nondeterminism while leaving the default
613    /// Quality (f32) path as the byte-identical baseline.
614    #[test]
615    fn raster_quality_modes_are_deterministic() {
616        let bytes = minimal_pdf_bytes();
617        let pdf = Pdf::new(bytes).expect("PDF should load");
618        for quality in [RasterQuality::Quality, RasterQuality::Speed] {
619            let render_once = || {
620                let page = &pdf.pages()[0];
621                render(
622                    page,
623                    &InterpreterSettings::default(),
624                    &RenderSettings {
625                        x_scale: 2.0,
626                        y_scale: 2.0,
627                        bg_color: WHITE,
628                        quality,
629                        ..Default::default()
630                    },
631                )
632            };
633            let a = render_once();
634            let b = render_once();
635            assert_eq!(
636                (a.width(), a.height()),
637                (b.width(), b.height()),
638                "{quality:?} dimensions must be stable"
639            );
640            assert_eq!(
641                a.data_as_u8_slice(),
642                b.data_as_u8_slice(),
643                "{quality:?} output must be byte-identical across runs"
644            );
645        }
646    }
647
648    /// `RasterQuality::Quality` is the default and must map to the f32 render
649    /// mode, keeping default output byte-identical to historical releases.
650    #[test]
651    fn raster_quality_default_is_quality() {
652        assert_eq!(RasterQuality::default(), RasterQuality::Quality);
653        assert_eq!(RenderSettings::default().quality, RasterQuality::Quality);
654    }
655
656    /// The default flows from the single named constant, so the T5-gated
657    /// flip (Quality -> Speed) is one line.
658    #[test]
659    fn raster_quality_default_is_the_named_constant() {
660        assert_eq!(RasterQuality::default(), DEFAULT_RASTER_QUALITY);
661        assert_eq!(DEFAULT_RASTER_QUALITY, RasterQuality::Quality);
662    }
663
664    /// Seam: each quality maps to its vello_cpu pipeline. `render()` feeds
665    /// this mapping into `RenderSettings.render_mode`, so the option reaches
666    /// the rasteriser by construction; the pixmap tests below prove both
667    /// pipelines render.
668    #[test]
669    fn raster_quality_maps_to_its_render_mode() {
670        assert!(matches!(
671            RasterQuality::Quality.render_mode(),
672            RenderMode::OptimizeQuality
673        ));
674        assert!(matches!(
675            RasterQuality::Speed.render_mode(),
676            RenderMode::OptimizeSpeed
677        ));
678    }
679
680    /// A one-page fixture of our own with filled + stroked content (the
681    /// diagonal stroke exercises anti-aliased edges), generated in-test.
682    fn aa_edge_pdf_bytes() -> Vec<u8> {
683        let content = b"<< /Length 55 >>\nstream\n1 0 0 rg 10 10 50 30 re f 0 0 1 RG 2 w 0 0 m 72 72 l S\nendstream"
684            .to_vec();
685        raw_pdf_bytes(&[
686            b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
687            b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
688            b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 72] /Contents 4 0 R >>".to_vec(),
689            content,
690        ])
691    }
692
693    /// Both modes produce a valid pixmap of the same size on content that
694    /// exercises fills, strokes and anti-aliased edges.
695    #[test]
696    fn raster_quality_modes_produce_same_size_pixmap() {
697        let bytes = aa_edge_pdf_bytes();
698        let pdf = Pdf::new(bytes).expect("PDF should load");
699        let page = &pdf.pages()[0];
700        let mut pixmaps = Vec::new();
701        for quality in [RasterQuality::Quality, RasterQuality::Speed] {
702            let pixmap = render(
703                page,
704                &InterpreterSettings::default(),
705                &RenderSettings {
706                    x_scale: 2.0,
707                    y_scale: 2.0,
708                    bg_color: WHITE,
709                    quality,
710                    ..Default::default()
711                },
712            );
713            assert_eq!((pixmap.width(), pixmap.height()), (144, 144));
714            assert!(!pixmap.data_as_u8_slice().is_empty());
715            pixmaps.push(pixmap);
716        }
717        assert_eq!(
718            (pixmaps[0].width(), pixmaps[0].height()),
719            (pixmaps[1].width(), pixmaps[1].height()),
720            "Quality and Speed must agree on dimensions"
721        );
722    }
723
724    /// `0` and `1` both select the single-threaded path; anything larger
725    /// passes through. Vello takes the multi-threaded path for any nonzero
726    /// count, so `1` must not reach it (#636).
727    #[test]
728    fn normalize_num_threads_maps_zero_and_one_to_single_threaded() {
729        assert_eq!(normalize_num_threads(0), 0);
730        assert_eq!(normalize_num_threads(1), 0);
731        assert_eq!(normalize_num_threads(2), 2);
732        assert_eq!(normalize_num_threads(8), 8);
733    }
734
735    /// `PDF_RENDER_THREADS=1` resolves to the single-threaded path, like `0`.
736    /// An unset or unparseable value falls back to the machine default.
737    #[test]
738    fn configured_num_threads_treats_one_as_single_threaded() {
739        assert_eq!(configured_num_threads_from_env(Some("1")), 0);
740        assert_eq!(configured_num_threads_from_env(Some("0")), 0);
741        assert_eq!(configured_num_threads_from_env(Some("4")), 4);
742        assert_eq!(
743            configured_num_threads_from_env(Some("bogus")),
744            configured_num_threads_from_env(None),
745            "an unparseable value must fall back to the default"
746        );
747    }
748
749    /// A page rendered from inside a rayon worker must use the single-threaded
750    /// path: dispatching to vello's own pool from a worker that then waits
751    /// overflows the worker's stack once enough page jobs are queued (#636).
752    /// Reads through the real (cached) entry point, because the worker guard
753    /// must hold no matter what the base resolved to.
754    #[cfg(not(target_arch = "wasm32"))]
755    #[test]
756    fn render_num_threads_is_zero_inside_rayon_worker() {
757        let pool = rayon::ThreadPoolBuilder::new()
758            .num_threads(1)
759            .build()
760            .expect("test pool should build");
761        pool.install(|| {
762            assert!(rayon::current_thread_index().is_some());
763            assert_eq!(render_num_threads(), 0);
764        });
765    }
766}