#![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 {
use std::sync::OnceLock;
static N: OnceLock<u16> = OnceLock::new();
*N.get_or_init(|| {
if let Some(n) = std::env::var("PDF_RENDER_THREADS")
.ok()
.and_then(|v| v.parse::<u16>().ok())
{
return n;
}
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::available_parallelism()
.map(|n| n.get().min(u16::MAX as usize) as u16)
.unwrap_or(1)
}
#[cfg(target_arch = "wasm32")]
{
0
}
})
}
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 renderer;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RasterQuality {
#[default]
Quality,
Speed,
}
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 {
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.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,
);
}
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);
}
#[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);
}
}