#![forbid(unsafe_code)]
#![deny(missing_docs)]
use crate::renderer::Renderer;
use kurbo::{Affine, Rect, Shape};
use pdf_interpret::Device;
use pdf_interpret::FillRule;
use pdf_interpret::InterpreterSettings;
use pdf_interpret::pdf_syntax::Pdf;
use pdf_interpret::pdf_syntax::page::Page;
use pdf_interpret::util::PageExt;
use pdf_interpret::{BlendMode, Context};
use pdf_interpret::{ClipPath, interpret_page};
use std::ops::RangeInclusive;
fn render_trace_enabled() -> bool {
use std::sync::OnceLock;
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
std::env::var("PDF_RENDER_TRACE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
})
}
fn render_num_threads() -> u16 {
#[cfg(not(target_arch = "wasm32"))]
if rayon::current_thread_index().is_some() {
return 0;
}
use std::sync::OnceLock;
static N: OnceLock<u16> = OnceLock::new();
*N.get_or_init(configured_num_threads)
}
fn configured_num_threads() -> u16 {
configured_num_threads_from_env(std::env::var("PDF_RENDER_THREADS").ok().as_deref())
}
fn configured_num_threads_from_env(raw: Option<&str>) -> u16 {
if let Some(n) = raw.and_then(|v| v.parse::<u16>().ok()) {
return normalize_num_threads(n);
}
#[cfg(not(target_arch = "wasm32"))]
{
normalize_num_threads(
std::thread::available_parallelism()
.map(|n| n.get().min(u16::MAX as usize) as u16)
.unwrap_or(1),
)
}
#[cfg(target_arch = "wasm32")]
{
0
}
}
fn normalize_num_threads(n: u16) -> u16 {
if n <= 1 { 0 } else { n }
}
pub use glyph_cache::GlyphOutlineCache;
pub use pdf_interpret;
pub use pdf_interpret::pdf_syntax;
pub use vello_cpu;
use vello_cpu::color::AlphaColor;
use vello_cpu::color::Srgb;
use vello_cpu::color::palette::css::TRANSPARENT;
use vello_cpu::color::palette::css::WHITE;
use vello_cpu::{Level, Pixmap, RenderMode};
mod glyph_cache;
mod renderer;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RasterQuality {
Quality,
Speed,
}
pub const DEFAULT_RASTER_QUALITY: RasterQuality = RasterQuality::Quality;
impl Default for RasterQuality {
fn default() -> Self {
DEFAULT_RASTER_QUALITY
}
}
impl RasterQuality {
fn render_mode(self) -> RenderMode {
match self {
RasterQuality::Quality => RenderMode::OptimizeQuality,
RasterQuality::Speed => RenderMode::OptimizeSpeed,
}
}
}
#[derive(Clone, Copy)]
pub struct RenderSettings {
pub x_scale: f32,
pub y_scale: f32,
pub width: Option<u16>,
pub height: Option<u16>,
pub bg_color: AlphaColor<Srgb>,
pub quality: RasterQuality,
}
impl Default for RenderSettings {
fn default() -> Self {
Self {
x_scale: 1.0,
y_scale: 1.0,
width: None,
height: None,
bg_color: TRANSPARENT,
quality: RasterQuality::default(),
}
}
}
pub fn render(
page: &Page<'_>,
interpreter_settings: &InterpreterSettings,
render_settings: &RenderSettings,
) -> Pixmap {
render_with_glyph_cache(
page,
interpreter_settings,
render_settings,
&mut GlyphOutlineCache::new(),
)
}
pub fn render_with_glyph_cache(
page: &Page<'_>,
interpreter_settings: &InterpreterSettings,
render_settings: &RenderSettings,
glyph_cache: &mut GlyphOutlineCache,
) -> Pixmap {
let (x_scale, y_scale) = (render_settings.x_scale, render_settings.y_scale);
let (width, height) = page.render_dimensions();
let (scaled_width, scaled_height) = ((width * x_scale) as f64, (height * y_scale) as f64);
let initial_transform =
Affine::scale_non_uniform(x_scale as f64, y_scale as f64) * page.initial_transform(true);
let (pix_width, pix_height) = (
render_settings
.width
.unwrap_or(scaled_width.round() as u16)
.max(1),
render_settings
.height
.unwrap_or(scaled_height.round() as u16)
.max(1),
);
let trace = render_trace_enabled();
let t_setup = trace.then(std::time::Instant::now);
let mut state = Context::new(
initial_transform,
Rect::new(0.0, 0.0, pix_width as f64, pix_height as f64),
page.xref(),
interpreter_settings.clone(),
);
let vc_settings = vello_cpu::RenderSettings {
level: Level::new(),
num_threads: render_num_threads(),
render_mode: render_settings.quality.render_mode(),
};
let mut device = Renderer::new(pix_width, pix_height, vc_settings);
device.set_glyph_cache(std::mem::take(glyph_cache));
device.ctx.set_paint(render_settings.bg_color);
device
.ctx
.fill_rect(&Rect::new(0.0, 0.0, pix_width as f64, pix_height as f64));
device.push_clip_path(&ClipPath {
path: Rect::new(0.0, 0.0, pix_width as f64, pix_height as f64).to_path(0.1),
fill: FillRule::NonZero,
});
device.push_transparency_group(1.0, None, BlendMode::Normal);
let setup_ms = t_setup.map(|t| t.elapsed().as_secs_f64() * 1000.0);
let t_interpret = trace.then(std::time::Instant::now);
interpret_page(page, &mut state, &mut device);
let interpret_ms = t_interpret.map(|t| t.elapsed().as_secs_f64() * 1000.0);
device.pop_transparency_group();
device.pop_clip_path();
let mut pixmap = Pixmap::new(pix_width, pix_height);
let t_raster = trace.then(std::time::Instant::now);
device.ctx.flush();
device.ctx.render_to_pixmap(&mut pixmap);
let raster_ms = t_raster.map(|t| t.elapsed().as_secs_f64() * 1000.0);
if trace {
eprintln!(
"PDF_RENDER_TRACE setup_ms={:.3} interpret_ms={:.2} raster_ms={:.2} w={} h={} threads={}",
setup_ms.unwrap_or(0.0),
interpret_ms.unwrap_or(0.0),
raster_ms.unwrap_or(0.0),
pix_width,
pix_height,
vc_settings.num_threads,
);
}
*glyph_cache = device.take_glyph_cache();
pixmap
}
#[doc(hidden)]
pub fn render_pdf(
pdf: &Pdf,
scale: f32,
settings: InterpreterSettings,
range: Option<RangeInclusive<usize>>,
) -> Option<Vec<Pixmap>> {
let rendered = pdf
.pages()
.iter()
.enumerate()
.flat_map(|(idx, page)| {
if range.clone().is_some_and(|range| !range.contains(&idx)) {
return None;
}
let pixmap = render(
page,
&settings,
&RenderSettings {
x_scale: scale,
y_scale: scale,
bg_color: WHITE,
..Default::default()
},
);
Some(pixmap)
})
.collect();
Some(rendered)
}
pub(crate) fn derive_settings(settings: &vello_cpu::RenderSettings) -> vello_cpu::RenderSettings {
vello_cpu::RenderSettings {
num_threads: 0,
..*settings
}
}
#[cfg(test)]
mod tests {
use super::*;
use pdf_interpret::InterpreterSettings;
use pdf_syntax::Pdf;
fn minimal_pdf_bytes() -> Vec<u8> {
use lopdf::{Document, Object, Stream, dictionary};
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
let content = Stream::new(dictionary! {}, b"".to_vec());
let content_id = doc.add_object(content);
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => Object::Name(b"Page".to_vec()),
"Parent" => Object::Reference(pages_id),
"MediaBox" => Object::Array(vec![
Object::Integer(0), Object::Integer(0),
Object::Integer(72), Object::Integer(72),
]),
"Contents" => Object::Reference(content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => Object::Name(b"Pages".to_vec()),
"Kids" => Object::Array(vec![Object::Reference(page_id)]),
"Count" => Object::Integer(1),
}),
);
let catalog_id = doc.new_object_id();
doc.objects.insert(
catalog_id,
Object::Dictionary(dictionary! {
"Type" => Object::Name(b"Catalog".to_vec()),
"Pages" => Object::Reference(pages_id),
}),
);
doc.trailer.set("Root", Object::Reference(catalog_id));
let mut bytes = Vec::new();
doc.save_to(&mut bytes).expect("lopdf save should succeed");
bytes
}
#[test]
fn render_pdf_returns_one_pixmap() {
let bytes = minimal_pdf_bytes();
let pdf = Pdf::new(bytes).expect("PDF should load");
let pixmaps = render_pdf(&pdf, 1.0, InterpreterSettings::default(), None);
assert!(pixmaps.is_some());
assert_eq!(pixmaps.unwrap().len(), 1);
}
fn raw_pdf_bytes(objects: &[Vec<u8>]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::with_capacity(objects.len());
for (i, body) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
}
let xref_pos = out.len();
out.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
out.extend_from_slice(b"0000000000 65535 f \n");
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n",
objects.len() + 1
)
.as_bytes(),
);
out
}
#[test]
fn downscaled_smasked_image_composites_without_fringe() {
let image = [
b"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 \
/ColorSpace /DeviceRGB /SMask 5 0 R /Length 6 >>\nstream\n"
.as_slice(),
[255, 0, 0, 0, 0, 0].as_slice(),
b"\nendstream".as_slice(),
]
.concat();
let smask = [
b"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 \
/ColorSpace /DeviceGray /Length 2 >>\nstream\n"
.as_slice(),
[255, 0].as_slice(),
b"\nendstream".as_slice(),
]
.concat();
let content = b"<< /Length 22 >>\nstream\n2 0 0 1 0 0 cm /Im0 Do\nendstream".to_vec();
let bytes = raw_pdf_bytes(&[
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 2 1] \
/Resources << /XObject << /Im0 4 0 R >> >> /Contents 6 0 R >>"
.to_vec(),
image,
smask,
content,
]);
let pdf = Pdf::new(bytes).expect("PDF should load");
let pixmaps = render_pdf(&pdf, 0.5, InterpreterSettings::default(), None).unwrap();
assert_eq!(pixmaps.len(), 1);
let pixmap = &pixmaps[0];
assert_eq!((pixmap.width(), pixmap.height()), (1, 1));
let px = pixmap.data_as_u8_slice();
assert_eq!(px.len(), 4);
assert!(
px[0] > 240 && (100..160).contains(&px[1]) && (100..160).contains(&px[2]),
"expected pinkish-white, got {px:?}"
);
}
#[test]
fn render_pdf_pixmap_matches_mediabox() {
let bytes = minimal_pdf_bytes();
let pdf = Pdf::new(bytes).expect("PDF should load");
let pixmaps = render_pdf(&pdf, 1.0, InterpreterSettings::default(), None).unwrap();
let pixmap = &pixmaps[0];
assert_eq!(pixmap.width(), 72);
assert_eq!(pixmap.height(), 72);
}
#[test]
fn render_pdf_with_scale_2_doubles_dimensions() {
let bytes = minimal_pdf_bytes();
let pdf = Pdf::new(bytes).expect("PDF should load");
let pixmaps = render_pdf(&pdf, 2.0, InterpreterSettings::default(), None).unwrap();
let pixmap = &pixmaps[0];
assert_eq!(pixmap.width(), 144);
assert_eq!(pixmap.height(), 144);
}
#[test]
fn render_pdf_page_range_selects_single_page() {
let bytes = minimal_pdf_bytes();
let pdf = Pdf::new(bytes).expect("PDF should load");
let pixmaps = render_pdf(&pdf, 1.0, InterpreterSettings::default(), Some(0..=0)).unwrap();
assert_eq!(pixmaps.len(), 1);
}
#[test]
fn render_pdf_is_byte_deterministic() {
let bytes = minimal_pdf_bytes();
let pdf = Pdf::new(bytes).expect("PDF should load");
let a = render_pdf(&pdf, 2.0, InterpreterSettings::default(), None).unwrap();
let b = render_pdf(&pdf, 2.0, InterpreterSettings::default(), None).unwrap();
assert_eq!(a.len(), b.len());
assert_eq!(
a[0].data_as_u8_slice(),
b[0].data_as_u8_slice(),
"render output must be byte-identical across runs"
);
}
#[test]
fn raster_quality_modes_are_deterministic() {
let bytes = minimal_pdf_bytes();
let pdf = Pdf::new(bytes).expect("PDF should load");
for quality in [RasterQuality::Quality, RasterQuality::Speed] {
let render_once = || {
let page = &pdf.pages()[0];
render(
page,
&InterpreterSettings::default(),
&RenderSettings {
x_scale: 2.0,
y_scale: 2.0,
bg_color: WHITE,
quality,
..Default::default()
},
)
};
let a = render_once();
let b = render_once();
assert_eq!(
(a.width(), a.height()),
(b.width(), b.height()),
"{quality:?} dimensions must be stable"
);
assert_eq!(
a.data_as_u8_slice(),
b.data_as_u8_slice(),
"{quality:?} output must be byte-identical across runs"
);
}
}
#[test]
fn raster_quality_default_is_quality() {
assert_eq!(RasterQuality::default(), RasterQuality::Quality);
assert_eq!(RenderSettings::default().quality, RasterQuality::Quality);
}
#[test]
fn raster_quality_default_is_the_named_constant() {
assert_eq!(RasterQuality::default(), DEFAULT_RASTER_QUALITY);
assert_eq!(DEFAULT_RASTER_QUALITY, RasterQuality::Quality);
}
#[test]
fn raster_quality_maps_to_its_render_mode() {
assert!(matches!(
RasterQuality::Quality.render_mode(),
RenderMode::OptimizeQuality
));
assert!(matches!(
RasterQuality::Speed.render_mode(),
RenderMode::OptimizeSpeed
));
}
fn aa_edge_pdf_bytes() -> Vec<u8> {
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"
.to_vec();
raw_pdf_bytes(&[
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 72] /Contents 4 0 R >>".to_vec(),
content,
])
}
#[test]
fn raster_quality_modes_produce_same_size_pixmap() {
let bytes = aa_edge_pdf_bytes();
let pdf = Pdf::new(bytes).expect("PDF should load");
let page = &pdf.pages()[0];
let mut pixmaps = Vec::new();
for quality in [RasterQuality::Quality, RasterQuality::Speed] {
let pixmap = render(
page,
&InterpreterSettings::default(),
&RenderSettings {
x_scale: 2.0,
y_scale: 2.0,
bg_color: WHITE,
quality,
..Default::default()
},
);
assert_eq!((pixmap.width(), pixmap.height()), (144, 144));
assert!(!pixmap.data_as_u8_slice().is_empty());
pixmaps.push(pixmap);
}
assert_eq!(
(pixmaps[0].width(), pixmaps[0].height()),
(pixmaps[1].width(), pixmaps[1].height()),
"Quality and Speed must agree on dimensions"
);
}
#[test]
fn normalize_num_threads_maps_zero_and_one_to_single_threaded() {
assert_eq!(normalize_num_threads(0), 0);
assert_eq!(normalize_num_threads(1), 0);
assert_eq!(normalize_num_threads(2), 2);
assert_eq!(normalize_num_threads(8), 8);
}
#[test]
fn configured_num_threads_treats_one_as_single_threaded() {
assert_eq!(configured_num_threads_from_env(Some("1")), 0);
assert_eq!(configured_num_threads_from_env(Some("0")), 0);
assert_eq!(configured_num_threads_from_env(Some("4")), 4);
assert_eq!(
configured_num_threads_from_env(Some("bogus")),
configured_num_threads_from_env(None),
"an unparseable value must fall back to the default"
);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn render_num_threads_is_zero_inside_rayon_worker() {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(1)
.build()
.expect("test pool should build");
pool.install(|| {
assert!(rayon::current_thread_index().is_some());
assert_eq!(render_num_threads(), 0);
});
}
}