pub mod content;
pub mod crypto;
pub mod error;
pub mod filters;
pub mod lexer;
pub mod objects;
pub mod page_tree;
pub mod resolver;
pub mod resources;
pub mod xref;
pub use error::PdfError;
pub use objects::{PdfDict, PdfObj};
pub use page_tree::PageInfo;
use content::ContentInterpreter;
use resolver::Resolver;
use std::collections::HashSet;
use std::sync::Arc;
use stet_fonts::geometry::Matrix;
use stet_graphics::display_list::DisplayList;
use stet_graphics::icc::IccCache;
pub type FontProvider = Arc<dyn Fn(&str) -> Option<Vec<u8>> + Send + Sync>;
pub struct PdfDocument<'a> {
resolver: Resolver<'a>,
pages: Vec<PageInfo>,
icc_cache: IccCache,
font_provider: Option<FontProvider>,
overprint: bool,
ocg_off: HashSet<u32>,
output_intent_icc: Option<Vec<u8>>,
}
impl<'a> PdfDocument<'a> {
pub fn from_bytes(data: &'a [u8]) -> Result<Self, PdfError> {
let mut icc_cache = IccCache::new();
icc_cache.search_system_cmyk_profile();
Self::from_bytes_inner(data, icc_cache, b"")
}
pub fn from_bytes_with_icc(data: &'a [u8], icc_cache: IccCache) -> Result<Self, PdfError> {
Self::from_bytes_inner(data, icc_cache, b"")
}
pub fn from_bytes_with_password(
data: &'a [u8],
icc_cache: IccCache,
password: &[u8],
) -> Result<Self, PdfError> {
Self::from_bytes_inner(data, icc_cache, password)
}
fn from_bytes_inner(
data: &'a [u8],
icc_cache: IccCache,
password: &[u8],
) -> Result<Self, PdfError> {
if !has_pdf_header(data) {
return Err(PdfError::NotAPdf);
}
let xref = xref::parse_xref(data)?;
let encryption = if let Some(encrypt_ref) = xref.trailer.get(b"Encrypt") {
if matches!(encrypt_ref, crate::objects::PdfObj::Null) {
None
} else {
let temp_resolver = Resolver::new(data, &xref);
let encrypt_obj = temp_resolver.deref(encrypt_ref)?;
let encrypt_dict = encrypt_obj
.as_dict()
.ok_or(PdfError::Other("Encrypt is not a dict".into()))?;
let file_id = xref
.trailer
.get_array(b"ID")
.and_then(|arr| arr.first()?.as_str().map(|s| s.to_vec()))
.unwrap_or_default();
Some(crypto::EncryptionState::try_open_with_password(
encrypt_dict,
&xref.trailer,
&file_id,
password,
)?)
}
} else {
None
};
let resolver = Resolver::with_encryption(data, xref, encryption);
let pages = page_tree::collect_pages(&resolver)?;
let ocg_off = parse_ocg_off(&resolver);
let output_intent_icc = parse_output_intent_icc(&resolver);
Ok(Self {
resolver,
pages,
icc_cache,
font_provider: None,
overprint: true,
ocg_off,
output_intent_icc,
})
}
pub fn set_overprint(&mut self, enabled: bool) {
self.overprint = enabled;
}
pub fn set_font_provider(&mut self, provider: FontProvider) {
self.font_provider = Some(provider);
}
pub fn page_count(&self) -> usize {
self.pages.len()
}
pub fn page_size(&self, page: usize) -> Result<(f64, f64), PdfError> {
let info = self
.pages
.get(page)
.ok_or(PdfError::PageOutOfRange(page, self.pages.len()))?;
let [llx, lly, urx, ury] = info.crop_box;
let (w, h) = ((urx - llx).abs(), (ury - lly).abs());
match info.rotate.rem_euclid(360) {
90 | 270 => Ok((h, w)),
_ => Ok((w, h)),
}
}
pub fn page_info(&self, page: usize) -> Result<&PageInfo, PdfError> {
self.pages
.get(page)
.ok_or(PdfError::PageOutOfRange(page, self.pages.len()))
}
pub fn page_contents(&self, page: usize) -> Result<Vec<u8>, PdfError> {
let info = self
.pages
.get(page)
.ok_or(PdfError::PageOutOfRange(page, self.pages.len()))?;
if info.contents.is_empty() {
return Ok(Vec::new());
}
let mut result = Vec::new();
for (i, &(obj_num, gen_num)) in info.contents.iter().enumerate() {
match self.resolver.stream_data(obj_num, gen_num) {
Ok(data) => {
if i > 0 && !result.is_empty() {
result.push(b'\n');
}
result.extend_from_slice(&data);
}
Err(_) => continue,
}
}
Ok(result)
}
pub fn render_page(&self, page: usize, dpi: f64) -> Result<DisplayList, PdfError> {
let info = self
.pages
.get(page)
.ok_or(PdfError::PageOutOfRange(page, self.pages.len()))?;
let [llx, lly, urx, ury] = info.crop_box;
let (page_w, page_h) = ((urx - llx).abs(), (ury - lly).abs());
let scale = dpi / 72.0;
let ctm = match info.rotate.rem_euclid(360) {
90 => {
Matrix::new(0.0, scale, scale, 0.0, 0.0, 0.0).concat(&Matrix::translate(-llx, -lly))
}
180 => {
Matrix::new(-scale, 0.0, 0.0, scale, page_w * scale, 0.0)
.concat(&Matrix::translate(-llx, -lly))
}
270 => {
Matrix::new(0.0, -scale, -scale, 0.0, page_h * scale, page_w * scale)
.concat(&Matrix::translate(-llx, -lly))
}
_ => {
Matrix::new(scale, 0.0, 0.0, -scale, -llx * scale, ury * scale)
}
};
let content_data = self.page_contents(page)?;
let mut interpreter = ContentInterpreter::new(
&self.resolver,
info.resources.clone(),
ctm,
&self.icc_cache,
self.font_provider.clone(),
self.overprint,
&self.ocg_off,
);
let page_group_is_cmyk = if let Ok(page_obj) = self.resolver.resolve(info.obj_num, 0)
&& let Some(page_dict) = page_obj.as_dict()
&& let Some(group_obj) = page_dict.get(b"Group")
&& let Ok(group_resolved) = self.resolver.deref(group_obj)
&& let Some(group_dict) = group_resolved.as_dict()
&& group_dict.get_name(b"CS") == Some(b"DeviceCMYK")
{
interpreter.set_page_group_cmyk();
true
} else {
false
};
if let Err(e) = interpreter.interpret_stream_public(&content_data) {
eprintln!("warning: content stream error: {}", e);
}
interpreter.unwind_gstate_stack();
if !info.annots.is_empty() {
interpreter.reset_clip_for_annotations();
for &(n, g) in &info.annots {
let _ = interpreter.render_annotation(n, g);
}
}
let mut dl = interpreter.into_display_list();
if page_group_is_cmyk {
dl.set_page_group_color_space(stet_graphics::display_list::GroupColorSpace::DeviceCMYK);
}
Ok(dl)
}
#[cfg(feature = "render")]
pub fn render_page_to_rgba(
&self,
page: usize,
dpi: f64,
) -> Result<(Vec<u8>, u32, u32), PdfError> {
let (page_w, page_h) = self.page_size(page)?;
let scale = dpi / 72.0;
let pixel_w = (page_w * scale).round() as u32;
let pixel_h = (page_h * scale).round() as u32;
let display_list = self.render_page(page, dpi)?;
let rgba = stet_render::render_to_rgba(
&display_list,
pixel_w,
pixel_h,
dpi,
Some(&self.icc_cache),
false,
);
Ok((rgba, pixel_w, pixel_h))
}
pub fn icc_cache(&self) -> &IccCache {
&self.icc_cache
}
pub fn output_intent_icc(&self) -> Option<&[u8]> {
self.output_intent_icc.as_deref()
}
pub fn apply_output_intent_as_default_cmyk(&mut self) -> bool {
let Some(bytes) = self.output_intent_icc.as_deref() else {
return false;
};
let Some(hash) = self.icc_cache.register_profile(bytes) else {
return false;
};
self.icc_cache.set_system_cmyk(bytes, hash);
true
}
pub fn resolver(&self) -> &Resolver<'a> {
&self.resolver
}
pub fn pages(&self) -> &[PageInfo] {
&self.pages
}
}
fn parse_ocg_off(resolver: &Resolver) -> HashSet<u32> {
let mut off = HashSet::new();
let mut catalog_owned;
let catalog_dict = if let Some(root_ref) = resolver.trailer().get_ref(b"Root") {
if let Ok(c) = resolver.resolve(root_ref.0, root_ref.1) {
catalog_owned = c;
match catalog_owned.as_dict() {
Some(d) if d.get(b"OCProperties").is_some() => d,
_ => match find_catalog(resolver) {
Some(c) => {
catalog_owned = c;
catalog_owned.as_dict().unwrap()
}
None => return off,
},
}
} else {
return off;
}
} else {
return off;
};
let oc_props = match catalog_dict.get(b"OCProperties") {
Some(obj) => match resolver.deref(obj) {
Ok(o) => o,
Err(_) => return off,
},
None => return off,
};
let oc_dict = match oc_props.as_dict() {
Some(d) => d,
None => return off,
};
let d_obj = match oc_dict.get(b"D") {
Some(obj) => match resolver.deref(obj) {
Ok(o) => o,
Err(_) => return off,
},
None => return off,
};
let d_dict = match d_obj.as_dict() {
Some(d) => d,
None => return off,
};
if let Some(off_obj) = d_dict.get(b"OFF") {
let off_resolved = resolver.deref(off_obj).unwrap_or_else(|_| off_obj.clone());
if let Some(off_arr) = off_resolved.as_array() {
for obj in off_arr {
if let Some((num, _gen)) = obj.as_ref() {
off.insert(num);
}
}
}
}
off
}
fn parse_output_intent_icc(resolver: &Resolver) -> Option<Vec<u8>> {
let mut catalog_owned;
let catalog_dict = if let Some(root_ref) = resolver.trailer().get_ref(b"Root") {
if let Ok(c) = resolver.resolve(root_ref.0, root_ref.1) {
catalog_owned = c;
match catalog_owned.as_dict() {
Some(d) if d.get(b"OutputIntents").is_some() => d,
_ => {
catalog_owned = find_catalog(resolver)?;
catalog_owned.as_dict()?
}
}
} else {
catalog_owned = find_catalog(resolver)?;
catalog_owned.as_dict()?
}
} else {
catalog_owned = find_catalog(resolver)?;
catalog_owned.as_dict()?
};
let intents_obj = resolver.deref(catalog_dict.get(b"OutputIntents")?).ok()?;
let intents_arr = intents_obj.as_array()?;
for entry in intents_arr {
let intent = match resolver.deref(entry) {
Ok(o) => o,
Err(_) => continue,
};
let Some(intent_dict) = intent.as_dict() else {
continue;
};
let Some(profile_obj) = intent_dict.get(b"DestOutputProfile") else {
continue;
};
let Ok(bytes) = resolver.stream_data_from_obj(profile_obj) else {
continue;
};
if bytes.len() >= 40 && &bytes[36..40] == b"acsp" && &bytes[16..20] == b"CMYK" {
return Some(bytes);
}
}
None
}
fn find_catalog(resolver: &Resolver) -> Option<PdfObj> {
let xref_len = resolver.xref_len();
for obj_num in 0..xref_len as u32 {
if let Ok(obj) = resolver.resolve(obj_num, 0) {
if let Some(dict) = obj.as_dict() {
if dict.get_name(b"Type") == Some(b"Catalog") && dict.get(b"Pages").is_some() {
return Some(obj);
}
}
}
}
None
}
fn has_pdf_header(data: &[u8]) -> bool {
let search_range = data.len().min(1024);
data[..search_range].windows(5).any(|w| w == b"%PDF-")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn not_a_pdf() {
let result = PdfDocument::from_bytes(b"not a pdf");
assert!(matches!(result, Err(PdfError::NotAPdf)));
}
#[test]
fn parse_minimal_pdf() {
let pdf = build_minimal_pdf();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
assert_eq!(doc.page_count(), 1);
let (w, h) = doc.page_size(0).unwrap();
assert_eq!(w, 612.0);
assert_eq!(h, 792.0);
}
#[test]
fn page_out_of_range() {
let pdf = build_minimal_pdf();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
assert!(matches!(
doc.page_size(5),
Err(PdfError::PageOutOfRange(5, 1))
));
}
#[test]
fn page_contents_empty() {
let pdf = build_minimal_pdf();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let contents = doc.page_contents(0).unwrap();
assert!(contents.is_empty());
}
#[test]
#[ignore]
fn dump_display_list() {
use stet_fonts::geometry::PsPath;
use stet_graphics::display_list::{DisplayElement, DisplayList};
fn path_bbox(path: &PsPath) -> String {
use stet_fonts::geometry::PathSegment;
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for seg in &path.segments {
let pts: Vec<(f64, f64)> = match seg {
PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => vec![(*x, *y)],
PathSegment::CurveTo {
x1,
y1,
x2,
y2,
x3,
y3,
} => vec![(*x1, *y1), (*x2, *y2), (*x3, *y3)],
PathSegment::ClosePath => vec![],
};
for (px, py) in pts {
x0 = x0.min(px);
y0 = y0.min(py);
x1 = x1.max(px);
y1 = y1.max(py);
}
}
format!("bbox=({:.0},{:.0},{:.0},{:.0})", x0, y0, x1, y1)
}
fn dump(list: &DisplayList, depth: usize) {
let indent = " ".repeat(depth);
for (i, elem) in list.elements().iter().enumerate() {
match elem {
DisplayElement::Fill { path, params } => {
let c = ¶ms.color;
let cmyk_str = if let Some((c2, m, y, k)) = params.color.native_cmyk {
format!(" cmyk=({:.2},{:.2},{:.2},{:.2})", c2, m, y, k)
} else {
String::new()
};
eprintln!(
"{indent}[{i}] Fill rgb=({:.2},{:.2},{:.2}){} op={} opm={} ch=0x{:x} a={:.2} {}",
c.r,
c.g,
c.b,
cmyk_str,
params.overprint,
params.overprint_mode,
params.painted_channels,
params.alpha,
path_bbox(path)
);
}
DisplayElement::Stroke { path, params } => {
let c = ¶ms.color;
eprintln!(
"{indent}[{i}] Stroke rgb=({:.2},{:.2},{:.2}) {}",
c.r,
c.g,
c.b,
path_bbox(path)
);
}
DisplayElement::Clip { path, .. } => {
eprintln!("{indent}[{i}] Clip {}", path_bbox(path))
}
DisplayElement::InitClip => eprintln!("{indent}[{i}] InitClip"),
DisplayElement::Image { params, .. } => {
eprintln!("{indent}[{i}] Image {}x{}", params.width, params.height);
}
DisplayElement::ErasePage => eprintln!("{indent}[{i}] ErasePage"),
DisplayElement::AxialShading { params } => {
eprintln!(
"{indent}[{i}] AxialShading cs={:?} stops={}",
params.color_space,
params.color_stops.len()
);
}
DisplayElement::RadialShading { params } => {
eprintln!(
"{indent}[{i}] RadialShading cs={:?} stops={} ext=({},{}) c0=({:.1},{:.1}) r0={:.1} c1=({:.1},{:.1}) r1={:.1} bbox={:?} op={} ch=0x{:x}",
params.color_space,
params.color_stops.len(),
params.extend_start,
params.extend_end,
params.x0,
params.y0,
params.r0,
params.x1,
params.y1,
params.r1,
params.bbox,
params.overprint,
params.painted_channels
);
if let Some(first) = params.color_stops.first() {
eprintln!(
"{indent} stop[0]: pos={:.3} rgb=({:.3},{:.3},{:.3}) raw={:?}",
first.position,
first.color.r,
first.color.g,
first.color.b,
first.raw_components
);
}
if let Some(last) = params.color_stops.last() {
eprintln!(
"{indent} stop[{}]: pos={:.3} rgb=({:.3},{:.3},{:.3}) raw={:?}",
params.color_stops.len() - 1,
last.position,
last.color.r,
last.color.g,
last.color.b,
last.raw_components
);
}
let mid = params.color_stops.len() / 2;
if mid > 0 && mid < params.color_stops.len() - 1 {
let s = ¶ms.color_stops[mid];
eprintln!(
"{indent} stop[{mid}]: pos={:.3} rgb=({:.3},{:.3},{:.3}) raw={:?}",
s.position, s.color.r, s.color.g, s.color.b, s.raw_components
);
}
}
DisplayElement::MeshShading { .. } => eprintln!("{indent}[{i}] MeshShading"),
DisplayElement::PatchShading { .. } => eprintln!("{indent}[{i}] PatchShading"),
DisplayElement::PatternFill { .. } => eprintln!("{indent}[{i}] PatternFill"),
DisplayElement::Text { .. } => eprintln!("{indent}[{i}] Text"),
DisplayElement::Group { elements, params } => {
eprintln!(
"{indent}[{i}] Group iso={} ko={} blend={} a={:.2} bbox=({:.0},{:.0},{:.0},{:.0}) children={}",
params.isolated,
params.knockout,
params.blend_mode,
params.alpha,
params.bbox[0],
params.bbox[1],
params.bbox[2],
params.bbox[3],
elements.len()
);
dump(elements, depth + 1);
}
DisplayElement::SoftMasked {
mask,
content,
params,
..
} => {
eprintln!(
"{indent}[{i}] SoftMasked {:?} mask={} content={}",
params.subtype,
mask.len(),
content.len()
);
eprintln!("{indent} MASK:");
dump(mask, depth + 2);
eprintln!("{indent} CONTENT:");
dump(content, depth + 2);
}
DisplayElement::OcgGroup {
elements,
ocg_id,
default_visible,
} => {
eprintln!(
"{indent}[{i}] OcgGroup id={} visible={} children={}",
ocg_id,
default_visible,
elements.len()
);
dump(elements, depth + 1);
}
}
}
}
let data = std::fs::read("../../pdf_samples/PDFX-ready_Output-Test_X4.pdf").unwrap();
let doc = PdfDocument::from_bytes(&data).unwrap();
let dl = doc.render_page(0, 72.0).unwrap();
eprintln!("=== Display list: {} top-level elements ===", dl.len());
dump(&dl, 0);
}
fn build_minimal_pdf() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.4\n");
let obj1_offset = pdf.len();
pdf.extend(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let obj2_offset = pdf.len();
pdf.extend(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
let obj3_offset = pdf.len();
pdf.extend(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n");
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 4\n");
pdf.extend(b"0000000000 65535 f\r\n");
pdf.extend(format!("{:010} 00000 n\r\n", obj1_offset).as_bytes());
pdf.extend(format!("{:010} 00000 n\r\n", obj2_offset).as_bytes());
pdf.extend(format!("{:010} 00000 n\r\n", obj3_offset).as_bytes());
pdf.extend(b"trailer\n<< /Size 4 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
}