#[cfg(feature = "raster")]
use crate::encode::OutputFormat;
use crate::pages::PageBoxes;
#[cfg(feature = "raster")]
use crate::raster::RenderConfig;
use crate::stream::{ColorRemap, ContentFilter};
#[cfg(feature = "raster")]
use image::DynamicImage;
use lopdf::Document;
use std::path::Path;
pub enum DocumentColorKind {
PureCMYK,
PureRGB,
Mixed,
Unknown,
}
pub struct PdfPipeline {
doc: Document,
}
impl PdfPipeline {
pub fn doc(&self) -> &Document {
&self.doc
}
pub fn open(path: impl AsRef<Path>) -> crate::Result<Self> {
let doc = Document::load(path)?;
Ok(Self { doc })
}
pub fn from_bytes(bytes: &[u8]) -> crate::Result<Self> {
let doc = Document::load_mem(bytes)?;
Ok(Self { doc })
}
pub fn trim(&mut self) -> crate::Result<&mut Self> {
ContentFilter::remove_outside_trim(&mut self.doc)?;
Ok(self)
}
pub fn resize(&mut self, bleed_pts: f64) -> crate::Result<&mut Self> {
let pages = self.doc.get_pages();
for &page_id in pages.values() {
let boxes = PageBoxes::read(&self.doc, page_id)?;
let new_media = boxes.bleed_rect(bleed_pts).to_pdf_array();
let page_dict = self.doc.get_dictionary_mut(page_id)?;
let arr: Vec<lopdf::Object> = new_media.iter().map(|&v| v.into()).collect();
let has_cropbox = page_dict.has(b"CropBox");
page_dict.set(b"MediaBox", arr.clone());
if has_cropbox {
page_dict.set(b"CropBox", arr);
}
}
Ok(self)
}
pub fn add_trim_box(&mut self, bleed_pts: f64) -> crate::Result<&mut Self> {
crate::pages::set_trim_boxes(&mut self.doc, bleed_pts)?;
Ok(self)
}
pub fn extract_pages(&self, page_nums: &[u32]) -> crate::Result<Self> {
let doc = crate::pages::extract_pages(&self.doc, page_nums)?;
Ok(Self { doc })
}
pub fn split_pages(&self, panel_width_pts: f64) -> crate::Result<Self> {
let doc = crate::pages::split_pages(&self.doc, panel_width_pts)?;
Ok(Self { doc })
}
pub fn stitch_pages(&self, spread_width_pts: f64) -> crate::Result<Self> {
let doc = crate::pages::stitch_pages(&self.doc, spread_width_pts)?;
Ok(Self { doc })
}
pub fn page_layout_hint(&self, page_idx: u32) -> (Vec<[f32; 4]>, Vec<[f32; 4]>) {
let pages = self.doc.get_pages();
match pages.get(&(page_idx + 1)).copied() {
Some(id) => crate::stream::page_layout(&self.doc, id),
None => (vec![], vec![]),
}
}
pub fn detect_color_space(doc: &Document) -> DocumentColorKind {
let mut has_cmyk = false;
let mut has_rgb = false;
for &page_id in doc.get_pages().values() {
let Ok(content) = doc.get_and_decode_page_content(page_id) else {
continue;
};
for op in &content.operations {
match op.operator.as_str() {
"k" | "K" => has_cmyk = true,
"rg" | "RG" => has_rgb = true,
_ => {}
}
if has_cmyk && has_rgb {
return DocumentColorKind::Mixed;
}
}
}
match (has_cmyk, has_rgb) {
(true, false) => DocumentColorKind::PureCMYK,
(false, true) => DocumentColorKind::PureRGB,
_ => DocumentColorKind::Unknown,
}
}
pub fn remap_color(
&mut self,
from: [f64; 4],
to: [f64; 4],
tolerance: f64,
) -> crate::Result<&mut Self> {
let remaps = ColorRemap {
from,
to,
tolerance,
};
ColorRemap::apply(&mut self.doc, &[remaps])?;
Ok(self)
}
#[cfg(feature = "color")]
pub fn flatten_spots(&mut self) -> crate::Result<u32> {
use rustybara_icc::pdf::flatten_spot_colors;
Ok(flatten_spot_colors(&mut self.doc)?)
}
#[cfg(feature = "color")]
pub fn convert_color_space(
&mut self,
from_profile: &str,
to_profile: &str,
intent: &str,
) -> crate::Result<()> {
use rustybara_icc::pdf::PdfColorConverter;
use rustybara_icc::{profiles, ColorTransform, IccError, RenderingIntent};
let from = profiles::by_name(from_profile)
.ok_or_else(|| IccError::Profile(format!("unknown source profile: {from_profile}")))?;
let to = profiles::by_name(to_profile).ok_or_else(|| {
IccError::Profile(format!("unknown destination profile: {to_profile}"))
})?;
let ri = match intent {
"Perceptual" => RenderingIntent::Perceptual,
"Saturation" => RenderingIntent::Saturation,
"AbsoluteColorimetric" => RenderingIntent::AbsoluteColorimetric,
_ => RenderingIntent::RelativeColorimetric,
};
let transform = ColorTransform::new(from, to, ri)?;
PdfColorConverter::new(&mut self.doc, transform).convert_document()?;
Ok(())
}
#[cfg(feature = "color")]
pub fn convert_color_space_raw(
&mut self,
from_bytes: &[u8],
to_bytes: &[u8],
intent: &str,
) -> crate::Result<()> {
use rustybara_icc::pdf::PdfColorConverter;
use rustybara_icc::{ColorTransform, RenderingIntent};
let ri = match intent {
"Perceptual" => RenderingIntent::Perceptual,
"Saturation" => RenderingIntent::Saturation,
"AbsoluteColorimetric" => RenderingIntent::AbsoluteColorimetric,
_ => RenderingIntent::RelativeColorimetric,
};
let transform = ColorTransform::from_bytes(from_bytes, to_bytes, ri)?;
PdfColorConverter::new(&mut self.doc, transform).convert_document()?;
Ok(())
}
pub fn page_count(&self) -> usize {
self.doc.get_pages().len()
}
pub fn save_pdf(&mut self, path: impl AsRef<Path>) -> crate::Result<()> {
self.doc.save(path)?;
Ok(())
}
pub fn to_bytes(&mut self) -> crate::Result<Vec<u8>> {
let mut buf = Vec::new();
self.doc.save_to(&mut buf).map_err(crate::Error::Io)?;
Ok(buf)
}
#[cfg(feature = "raster")]
pub fn render_page(&self, page_num: u32, config: &RenderConfig) -> crate::Result<DynamicImage> {
use pdfium_render::prelude::*;
let mut doc_clone = self.doc.clone();
let mut buf = Vec::new();
doc_clone.save_to(&mut buf).map_err(crate::Error::Io)?;
let dylib_name = if cfg!(target_os = "windows") {
"pdfium.dll"
} else if cfg!(target_os = "macos") {
"libpdfium.dylib"
} else {
"libpdfium.so" };
let bindings_result = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.join(dylib_name)))
.and_then(|lib| Pdfium::bind_to_library(lib).ok())
.map_or_else(|| Pdfium::bind_to_system_library(), Ok);
let pdfium = match bindings_result {
Ok(bindings) => Pdfium::new(bindings),
Err(_) => Pdfium,
};
let pdf_doc = pdfium.load_pdf_from_byte_vec(buf, None)?;
let page = pdf_doc.pages().get(page_num as PdfPageIndex)?;
crate::raster::render_page(&page, config)
}
#[cfg(feature = "raster")]
pub fn save_page_image(
&self,
page_num: u32,
path: impl AsRef<Path>,
format: &OutputFormat,
config: &RenderConfig,
) -> crate::Result<()> {
let image = self.render_page(page_num, config)?;
crate::encode::save(&image, path.as_ref(), format, config.dpi)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pages::PageBoxes;
fn fixture() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/pdf_test_data_print_v2.pdf")
}
#[test]
fn open_and_page_count() {
let p = PdfPipeline::open(fixture()).unwrap();
assert!(p.page_count() > 0);
}
#[test]
fn open_nonexistent_fails() {
let err = PdfPipeline::open("no_such_file.pdf");
assert!(err.is_err());
}
#[test]
fn trim_succeeds() {
let mut p = PdfPipeline::open(fixture()).unwrap();
p.trim().unwrap();
}
#[test]
fn trim_is_chainable() {
let mut p = PdfPipeline::open(fixture()).unwrap();
let out = std::env::temp_dir().join("rustybara_pipeline_trim_chain.pdf");
p.trim().unwrap().save_pdf(&out).unwrap();
assert!(out.exists());
std::fs::remove_file(&out).ok();
}
#[test]
fn resize_expands_mediabox() {
let bleed = 9.0;
let mut p = PdfPipeline::open(fixture()).unwrap();
let orig_doc = Document::load(fixture()).unwrap();
let orig_pages = orig_doc.get_pages();
let first_id = *orig_pages.values().next().unwrap();
let orig_boxes = PageBoxes::read(&orig_doc, first_id).unwrap();
let orig_trim = orig_boxes.trim_or_media();
p.resize(bleed).unwrap();
let pages = p.doc.get_pages();
let page_id = *pages.values().next().unwrap();
let boxes = PageBoxes::read(&p.doc, page_id).unwrap();
let media = boxes.media_box;
assert!(
(media.width - (orig_trim.width + 2.0 * bleed)).abs() < 0.01,
"media width should be trim + 2*bleed"
);
assert!(
(media.height - (orig_trim.height + 2.0 * bleed)).abs() < 0.01,
"media height should be trim + 2*bleed"
);
}
#[test]
fn save_roundtrip() {
let mut p = PdfPipeline::open(fixture()).unwrap();
let original_count = p.page_count();
let out = std::env::temp_dir().join("rustybara_pipeline_roundtrip.pdf");
p.trim().unwrap().save_pdf(&out).unwrap();
let reopened = PdfPipeline::open(&out).unwrap();
assert_eq!(reopened.page_count(), original_count);
std::fs::remove_file(&out).ok();
}
#[test]
fn resize_then_save() {
let mut p = PdfPipeline::open(fixture()).unwrap();
let out = std::env::temp_dir().join("rustybara_pipeline_resize_save.pdf");
p.resize(9.0).unwrap().save_pdf(&out).unwrap();
assert!(out.exists());
let reopened = PdfPipeline::open(&out).unwrap();
assert!(reopened.page_count() > 0);
std::fs::remove_file(&out).ok();
}
#[test]
fn trim_then_resize_pipeline() {
let mut p = PdfPipeline::open(fixture()).unwrap();
let out = std::env::temp_dir().join("rustybara_pipeline_trim_resize.pdf");
p.trim()
.unwrap()
.resize(9.0)
.unwrap()
.save_pdf(&out)
.unwrap();
assert!(out.exists());
std::fs::remove_file(&out).ok();
}
#[test]
#[cfg(feature = "raster")]
#[ignore = "requires pdfium runtime library"]
fn render_page_produces_image() {
let p = PdfPipeline::open(fixture()).unwrap();
let config = RenderConfig::default();
let img = p.render_page(0, &config).unwrap();
assert!(img.width() > 0);
assert!(img.height() > 0);
}
}