pub(crate) mod blend_nonsep;
pub(crate) mod ext_gstate;
pub(crate) mod mesh_shading;
pub(crate) mod page_renderer;
mod path_rasterizer;
pub(crate) mod resolution;
pub(crate) mod separation_renderer;
pub mod sidecar;
mod text_rasterizer;
pub use page_renderer::{
ImageFormat, PageRenderer, RenderOptions, RenderedImage, DEFAULT_MAX_OUTPUT_PIXELS,
};
pub use separation_renderer::{render_separation, render_separations, SeparationPlate};
use crate::content::GraphicsState;
use crate::error::Result;
use tiny_skia::{Color, Paint};
pub(crate) fn create_fill_paint(gs: &GraphicsState, blend_mode: &str) -> Paint<'static> {
let (r, g, b) = gs.fill_color_rgb;
let mut paint = Paint::default();
paint.set_color(Color::from_rgba(r, g, b, gs.fill_alpha).unwrap_or(Color::BLACK));
paint.anti_alias = true;
if blend_mode != "Normal" {
paint.blend_mode = pdf_blend_mode_to_skia(blend_mode);
}
paint
}
pub(crate) fn create_stroke_paint(gs: &GraphicsState, blend_mode: &str) -> Paint<'static> {
let (r, g, b) = gs.stroke_color_rgb;
let mut paint = Paint::default();
paint.set_color(Color::from_rgba(r, g, b, gs.stroke_alpha).unwrap_or(Color::BLACK));
paint.anti_alias = true;
if blend_mode != "Normal" {
paint.blend_mode = pdf_blend_mode_to_skia(blend_mode);
}
paint
}
pub(crate) fn pdf_blend_mode_to_skia(mode: &str) -> tiny_skia::BlendMode {
match mode {
"Normal" => tiny_skia::BlendMode::SourceOver,
"Multiply" => tiny_skia::BlendMode::Multiply,
"Screen" => tiny_skia::BlendMode::Screen,
"Overlay" => tiny_skia::BlendMode::Overlay,
"Darken" => tiny_skia::BlendMode::Darken,
"Lighten" => tiny_skia::BlendMode::Lighten,
"ColorDodge" => tiny_skia::BlendMode::ColorDodge,
"ColorBurn" => tiny_skia::BlendMode::ColorBurn,
"HardLight" => tiny_skia::BlendMode::HardLight,
"SoftLight" => tiny_skia::BlendMode::SoftLight,
"Difference" => tiny_skia::BlendMode::Difference,
"Exclusion" => tiny_skia::BlendMode::Exclusion,
_ => tiny_skia::BlendMode::SourceOver,
}
}
const MAX_DEVICE_COORD: f64 = 5.0e8;
pub(crate) fn page_render_box(
media_box: &crate::geometry::Rect,
crop_box: Option<&crate::geometry::Rect>,
) -> crate::geometry::Rect {
let Some(crop) = crop_box else {
return *media_box;
};
let x0 = crop.x.max(media_box.x);
let y0 = crop.y.max(media_box.y);
let x1 = (crop.x + crop.width).min(media_box.x + media_box.width);
let y1 = (crop.y + crop.height).min(media_box.y + media_box.height);
if x1 <= x0 || y1 <= y0 {
return *media_box;
}
crate::geometry::Rect::from_points(x0, y0, x1, y1)
}
pub(crate) fn rotated_page_extent(media_box: &crate::geometry::Rect, rotation: i32) -> (f32, f32) {
if rotation.rem_euclid(360) == 90 || rotation.rem_euclid(360) == 270 {
(media_box.height, media_box.width)
} else {
(media_box.width, media_box.height)
}
}
pub(crate) fn page_base_transform(
media_box: &crate::geometry::Rect,
rotation: i32,
scale: f32,
) -> tiny_skia::Transform {
use tiny_skia::Transform;
let origin = Transform::from_translate(-media_box.x, -media_box.y);
let (_, page_h) = rotated_page_extent(media_box, rotation);
match rotation.rem_euclid(360) {
90 => origin.post_concat(Transform::from_row(0.0, scale, scale, 0.0, 0.0, 0.0)),
180 => origin
.post_scale(-scale, scale)
.post_translate(media_box.width * scale, 0.0),
270 => origin.post_concat(Transform::from_row(
0.0,
-scale,
-scale,
0.0,
media_box.height * scale,
media_box.width * scale,
)),
_ => origin
.post_scale(scale, -scale)
.post_translate(0.0, page_h * scale),
}
}
const MAX_DEVICE_STROKE_REACH: f64 = 5.0e8;
fn device_bounds(path: &tiny_skia::Path, transform: tiny_skia::Transform) -> Option<[f64; 4]> {
let (sx, kx, ky, sy, tx, ty) = (
f64::from(transform.sx),
f64::from(transform.kx),
f64::from(transform.ky),
f64::from(transform.sy),
f64::from(transform.tx),
f64::from(transform.ty),
);
let b = path.bounds();
let (left, top) = (f64::from(b.left()), f64::from(b.top()));
let (right, bottom) = (f64::from(b.right()), f64::from(b.bottom()));
let mut out = [
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
];
for (x, y) in [(left, top), (right, top), (left, bottom), (right, bottom)] {
let dx = sx * x + kx * y + tx;
let dy = ky * x + sy * y + ty;
if !dx.is_finite() || !dy.is_finite() {
return None;
}
out[0] = out[0].min(dx);
out[1] = out[1].min(dy);
out[2] = out[2].max(dx);
out[3] = out[3].max(dy);
}
Some(out)
}
pub(crate) fn device_bounds_rasterizable(
path: &tiny_skia::Path,
transform: tiny_skia::Transform,
) -> bool {
device_bounds(path, transform).is_some_and(|b| b.iter().all(|c| c.abs() <= MAX_DEVICE_COORD))
}
pub(crate) fn device_bounds_miss_pixmap(
path: &tiny_skia::Path,
transform: tiny_skia::Transform,
width: u32,
height: u32,
) -> bool {
device_bounds(path, transform).is_some_and(|[min_x, min_y, max_x, max_y]| {
max_x < 0.0 || max_y < 0.0 || min_x > f64::from(width) || min_y > f64::from(height)
})
}
fn rasterizable_on(
path: &tiny_skia::Path,
transform: tiny_skia::Transform,
device_reach: f64,
width: u32,
height: u32,
) -> bool {
let Some([x0, y0, x1, y1]) = device_bounds(path, transform) else {
return false;
};
if x1 + device_reach < 0.0
|| y1 + device_reach < 0.0
|| x0 - device_reach > f64::from(width)
|| y0 - device_reach > f64::from(height)
{
return false;
}
x0.abs().max(x1.abs()) <= MAX_DEVICE_COORD && y0.abs().max(y1.abs()) <= MAX_DEVICE_COORD
}
fn device_scale(transform: tiny_skia::Transform) -> f64 {
let det = f64::from(transform.sx) * f64::from(transform.sy)
- f64::from(transform.kx) * f64::from(transform.ky);
det.abs().sqrt()
}
fn stroke_reach(stroke: &tiny_skia::Stroke) -> f64 {
f64::from(stroke.width.abs()) / 2.0 * f64::from(stroke.miter_limit.max(1.0))
}
fn clamp_stroke_reach(
stroke: &tiny_skia::Stroke,
transform: tiny_skia::Transform,
) -> Option<tiny_skia::Stroke> {
let reach = stroke_reach(stroke) * device_scale(transform);
if !reach.is_finite() || reach <= MAX_DEVICE_STROKE_REACH {
return None;
}
let mut clamped = stroke.clone();
clamped.width = (f64::from(stroke.width.abs()) * MAX_DEVICE_STROKE_REACH / reach) as f32;
Some(clamped)
}
pub(crate) fn guarded_fill_path(
pixmap: &mut tiny_skia::Pixmap,
path: &tiny_skia::Path,
paint: &Paint<'_>,
fill_rule: tiny_skia::FillRule,
transform: tiny_skia::Transform,
clip_mask: Option<&tiny_skia::Mask>,
) {
if !rasterizable_on(path, transform, 0.0, pixmap.width(), pixmap.height()) {
log::debug!("skipping unrasterizable draw: {:?}", path.bounds());
return;
}
pixmap.fill_path(path, paint, fill_rule, transform, clip_mask);
}
pub(crate) fn guarded_stroke_path(
pixmap: &mut tiny_skia::Pixmap,
path: &tiny_skia::Path,
paint: &Paint<'_>,
stroke: &tiny_skia::Stroke,
transform: tiny_skia::Transform,
clip_mask: Option<&tiny_skia::Mask>,
) {
let clamped = clamp_stroke_reach(stroke, transform);
let stroke = clamped.as_ref().unwrap_or(stroke);
let reach = stroke_reach(stroke) * device_scale(transform);
if !rasterizable_on(path, transform, reach, pixmap.width(), pixmap.height()) {
log::debug!("skipping unrasterizable stroke: {:?}", path.bounds());
return;
}
pixmap.stroke_path(path, paint, stroke, transform, clip_mask);
}
pub(crate) fn guarded_mask_fill_path(
mask: &mut tiny_skia::Mask,
path: &tiny_skia::Path,
fill_rule: tiny_skia::FillRule,
anti_alias: bool,
transform: tiny_skia::Transform,
) {
if !rasterizable_on(path, transform, 0.0, mask.width(), mask.height()) {
log::debug!("skipping unrasterizable draw: {:?}", path.bounds());
return;
}
mask.fill_path(path, fill_rule, anti_alias, transform);
}
pub(crate) fn pdf_blend_mode_is_nonseparable(
mode: &str,
) -> Option<blend_nonsep::NonSeparableBlend> {
blend_nonsep::NonSeparableBlend::from_name(mode)
}
pub(crate) fn paint_with_nonsep_blend<F>(
dest: &mut tiny_skia::Pixmap,
mode: blend_nonsep::NonSeparableBlend,
paint: F,
) where
F: FnOnce(&mut tiny_skia::Pixmap),
{
let w = dest.width();
let h = dest.height();
let mut scratch = match tiny_skia::Pixmap::new(w, h) {
Some(p) => p,
None => {
paint(dest);
return;
},
};
paint(&mut scratch);
blend_nonsep::compose_in_place(dest.data_mut(), scratch.data(), mode);
}
pub fn render_page(
doc: &crate::document::PdfDocument,
page_num: usize,
options: &RenderOptions,
) -> Result<RenderedImage> {
let mut renderer = PageRenderer::new(options.clone());
renderer.render_page(doc, page_num)
}
pub fn render_page_region(
doc: &crate::document::PdfDocument,
page_num: usize,
crop_rect_pt: (f32, f32, f32, f32),
options: &RenderOptions,
) -> Result<RenderedImage> {
let full = render_page(doc, page_num, options)?;
let (crop_x_pt, crop_y_pt, crop_w_pt, crop_h_pt) = crop_rect_pt;
if crop_w_pt <= 0.0 || crop_h_pt <= 0.0 {
return Err(crate::Error::InvalidPdf(format!("invalid crop rect: {crop_rect_pt:?}")));
}
let media = doc.get_page_media_box(page_num)?;
let page_h_pt = media.3 - media.1;
let scale = options.dpi as f32 / 72.0;
let crop_x_px = (crop_x_pt * scale).round().max(0.0) as u32;
let top_y_pt = page_h_pt - (crop_y_pt + crop_h_pt);
let crop_y_px = (top_y_pt * scale).round().max(0.0) as u32;
let crop_w_px = (crop_w_pt * scale).round().max(1.0) as u32;
let crop_h_px = (crop_h_pt * scale).round().max(1.0) as u32;
let full_img = image::load_from_memory(&full.data)
.map_err(|e| crate::Error::InvalidPdf(format!("render output decode: {e}")))?;
let x = crop_x_px.min(full_img.width().saturating_sub(1));
let y = crop_y_px.min(full_img.height().saturating_sub(1));
let w = crop_w_px.min(full_img.width() - x);
let h = crop_h_px.min(full_img.height() - y);
let cropped = full_img.crop_imm(x, y, w, h);
let mut buf = Vec::new();
match options.format {
ImageFormat::Jpeg => {
use image::ImageEncoder;
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(
&mut buf,
options.jpeg_quality.clamp(1, 100),
);
encoder
.write_image(cropped.as_bytes(), w, h, cropped.color().into())
.map_err(|e| crate::Error::InvalidPdf(format!("jpeg encode: {e}")))?;
},
_ => {
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::ImageEncoder;
PngEncoder::new_with_quality(&mut buf, CompressionType::Fast, FilterType::Sub)
.write_image(cropped.as_bytes(), w, h, cropped.color().into())
.map_err(|e| crate::Error::InvalidPdf(format!("png encode: {e}")))?;
},
}
Ok(RenderedImage {
data: buf,
width: w,
height: h,
format: full.format,
})
}
pub fn render_page_fit(
doc: &crate::document::PdfDocument,
page_num: usize,
fit_w_px: u32,
fit_h_px: u32,
options: &RenderOptions,
) -> Result<RenderedImage> {
if fit_w_px == 0 || fit_h_px == 0 {
return Err(crate::Error::InvalidPdf("fit width/height must be positive".into()));
}
let page_info = doc.get_page_info(page_num)?;
let rotation = page_info.rotation.rem_euclid(360);
let (page_w_pt, page_h_pt) = if rotation == 90 || rotation == 270 {
(page_info.media_box.height.max(1.0), page_info.media_box.width.max(1.0))
} else {
(page_info.media_box.width.max(1.0), page_info.media_box.height.max(1.0))
};
let scale = (fit_w_px as f32 / page_w_pt).min(fit_h_px as f32 / page_h_pt);
let mut opts = options.clone();
opts.scale_override = Some(scale);
render_page(doc, page_num, &opts)
}
pub fn flatten_to_images(doc: &crate::document::PdfDocument, dpi: u32) -> Result<Vec<u8>> {
let page_count = doc.page_count()?;
let options = RenderOptions::with_dpi(dpi);
let tmp_dir = std::env::temp_dir().join(format!("pdf_oxide_flatten_{}", std::process::id()));
std::fs::create_dir_all(&tmp_dir)?;
let mut paths: Vec<String> = Vec::new();
for page_idx in 0..page_count {
let mut renderer = PageRenderer::new(options.clone());
let rendered = renderer.render_page(doc, page_idx)?;
let path = tmp_dir.join(format!("page_{}.png", page_idx));
std::fs::write(&path, &rendered.data)?;
paths.push(path.to_string_lossy().to_string());
}
let pdf = crate::api::Pdf::from_images(&paths)?;
let bytes = pdf.into_bytes();
let _ = std::fs::remove_dir_all(&tmp_dir);
Ok(bytes)
}