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}