pub mod annotations;
pub mod content;
pub mod crypto;
pub mod destination;
pub mod diagnostics;
pub mod embedded_files;
pub mod error;
pub mod filters;
pub mod form_fields;
pub mod layers;
pub mod lexer;
pub mod metadata;
pub mod name_tree;
pub mod objects;
pub mod outline;
pub mod page_boxes;
pub mod page_tree;
pub mod resolver;
pub mod resources;
pub mod viewer_prefs;
pub mod xref;
pub use annotations::{
Annotation, AnnotationColor, AnnotationDate, AnnotationFlags, AnnotationKind,
AnnotationKindData, Border, CaretAnnotation, FileAttachmentAnnotation, FreeTextAnnotation,
InkAnnotation, LineAnnotation, LinkAnnotation, MarkupAnnotation, PolygonAnnotation,
PopupAnnotation, ShapeAnnotation, StampAnnotation, TextAnnotation,
};
pub use destination::{Action, Destination, ViewSpec};
pub use diagnostics::{LocationHint, ParsePhase, ParseWarning, Severity, WarningSink};
pub use embedded_files::{AfRelationship, EmbeddedFile};
pub use error::PdfError;
pub use form_fields::{
ButtonField, ButtonType, ChoiceField, ChoiceOption, FieldFlags, FieldKind, FieldValue,
FormCatalog, FormField, SigFlags, SignatureField, TextField,
};
pub use layers::{
AutoStateEvent, AutoStateRule, BaseState, Configuration, CreatorInfo, ExportUsage,
LanguageUsage, Layer, LayerIntent, LayerSet, LayerTree, LayerTreeNode, LayerUsage, ListMode,
MembershipPolicy, OcgVisibility, PageElementSubtype, PrintUsage, RenderIntent, UsageState,
UserUsage, ViewUsage, VisibilityExpr, ZoomUsage,
};
pub use metadata::{DocumentMetadata, PdfDate, TrappedFlag};
pub use objects::{PdfDict, PdfObj};
pub use outline::{OutlineItem, OutlineStyle};
pub use page_boxes::PageBoxes;
pub use page_tree::PageInfo;
pub use viewer_prefs::{
Duplex, PageLayout, PageMode, PrintScaling, ReadingDirection, ViewerPreferences,
};
use content::ContentInterpreter;
use resolver::Resolver;
use std::cell::OnceCell;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use stet_fonts::geometry::Matrix;
use stet_graphics::display_list::DisplayList;
use stet_graphics::document_structure::OutputIntentRecord;
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>>,
metadata_cache: OnceCell<DocumentMetadata>,
viewer_prefs_cache: OnceCell<ViewerPreferences>,
outline_cache: OnceCell<Vec<OutlineItem>>,
destinations_cache: OnceCell<HashMap<String, Destination>>,
page_annotations_cache: Vec<OnceCell<Vec<Annotation>>>,
form_cache: OnceCell<Option<FormCatalog>>,
embedded_files_cache: OnceCell<HashMap<String, EmbeddedFile>>,
layers_cache: OnceCell<Vec<Layer>>,
configurations_cache: OnceCell<Vec<Configuration>>,
warnings: WarningSink,
}
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);
let page_annotations_cache = (0..pages.len()).map(|_| OnceCell::new()).collect();
Ok(Self {
resolver,
pages,
icc_cache,
font_provider: None,
overprint: true,
ocg_off,
output_intent_icc,
metadata_cache: OnceCell::new(),
viewer_prefs_cache: OnceCell::new(),
outline_cache: OnceCell::new(),
destinations_cache: OnceCell::new(),
page_annotations_cache,
form_cache: OnceCell::new(),
embedded_files_cache: OnceCell::new(),
layers_cache: OnceCell::new(),
configurations_cache: OnceCell::new(),
warnings: WarningSink::new(),
})
}
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 explicit_cmyk_group = 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")
{
true
} else {
false
};
let page_group_is_cmyk = explicit_cmyk_group || self.output_intent_icc.is_some();
if page_group_is_cmyk {
interpreter.set_page_group_cmyk();
}
if self.output_intent_icc.is_some() {
interpreter.set_pdfx_cmyk_intent();
}
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> {
self.render_page_to_rgba_with_layers(page, dpi, &LayerSet::new())
}
#[cfg(feature = "render")]
pub fn render_page_to_rgba_with_layers(
&self,
page: usize,
dpi: f64,
layer_set: &LayerSet,
) -> 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_with_layers(
&display_list,
pixel_w,
pixel_h,
dpi,
Some(&self.icc_cache),
false,
layer_set,
);
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 hash = stet_graphics::icc::IccCache::hash_profile(bytes);
self.icc_cache.set_system_cmyk(bytes, hash);
self.icc_cache.set_proofing_enabled(true);
if self.icc_cache.register_profile(bytes).is_none() {
self.icc_cache.set_proofing_enabled(false);
return false;
}
self.icc_cache.prepare_reverse_cmyk();
self.icc_cache.prepare_lab_to_oi_cmyk();
true
}
pub fn output_intents(&self) -> Vec<OutputIntentRecord> {
parse_output_intents_full(&self.resolver)
}
pub fn resolver(&self) -> &Resolver<'a> {
&self.resolver
}
pub fn pages(&self) -> &[PageInfo] {
&self.pages
}
pub fn metadata(&self) -> &DocumentMetadata {
self.metadata_cache
.get_or_init(|| metadata::parse_document_metadata(&self.resolver))
}
pub fn viewer_preferences(&self) -> &ViewerPreferences {
self.viewer_prefs_cache
.get_or_init(|| viewer_prefs::parse_viewer_preferences(&self.resolver))
}
pub fn outline(&self) -> &[OutlineItem] {
self.outline_cache.get_or_init(|| {
outline::parse_outline_tree(&self.resolver, &self.pages, &self.warnings)
})
}
pub fn destinations(&self) -> &HashMap<String, Destination> {
self.destinations_cache
.get_or_init(|| destination::parse_named_destinations(&self.resolver, &self.pages))
}
pub fn resolve_named_destination(&self, name: &str) -> Option<Destination> {
self.destinations().get(name).cloned()
}
pub fn page_annotations(&self, page: usize) -> Result<&[Annotation], PdfError> {
if page >= self.pages.len() {
return Err(PdfError::PageOutOfRange(page, self.pages.len()));
}
let cell = &self.page_annotations_cache[page];
let annots = cell.get_or_init(|| {
annotations::parse_page_annotations(&self.resolver, &self.pages, page, &self.warnings)
});
Ok(annots.as_slice())
}
pub fn form(&self) -> Option<&FormCatalog> {
self.form_cache
.get_or_init(|| form_fields::parse_acroform(&self.resolver, &self.warnings))
.as_ref()
}
pub fn parse_warnings(&self) -> std::cell::Ref<'_, [ParseWarning]> {
self.warnings.borrow_slice()
}
pub fn page_boxes(&self, page: usize) -> Result<PageBoxes, PdfError> {
page_boxes::parse_page_boxes(&self.resolver, &self.pages, page)
.ok_or(PdfError::PageOutOfRange(page, self.pages.len()))
}
pub fn embedded_files(&self) -> &HashMap<String, EmbeddedFile> {
self.embedded_files_cache
.get_or_init(|| embedded_files::parse_embedded_files(&self.resolver))
}
pub fn embedded_file_bytes(&self, name: &str) -> Result<Vec<u8>, PdfError> {
let ef = self
.embedded_files()
.get(name)
.ok_or_else(|| PdfError::Other(format!("embedded file not found: {name}")))?;
embedded_files::decode_embedded_file_stream(
&self.resolver,
ef.stream_obj_num,
ef.stream_gen_num,
)
}
pub fn layers(&self) -> &[Layer] {
self.layers_cache
.get_or_init(|| layers::metadata::parse_layers(&self.resolver, &self.warnings))
.as_slice()
}
pub fn layer(&self, ocg_id: u32) -> Option<&Layer> {
self.layers().iter().find(|l| l.ocg_id == ocg_id)
}
pub fn configurations(&self) -> &[Configuration] {
self.configurations_cache
.get_or_init(|| {
layers::configuration::parse_configurations(&self.resolver, &self.warnings)
})
.as_slice()
}
pub fn default_configuration(&self) -> Option<&Configuration> {
self.configurations().first()
}
pub fn configuration(&self, index: usize) -> Option<&Configuration> {
self.configurations().get(index)
}
pub fn layer_tree(&self) -> LayerTree {
self.default_configuration()
.map(|c| c.order.clone())
.unwrap_or_default()
}
pub fn layer_set_for(&self, intent: RenderIntent) -> LayerSet {
layers::layer_set_for(self, intent)
}
}
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 parse_output_intents_full(resolver: &Resolver) -> Vec<OutputIntentRecord> {
let catalog_obj = match resolver.trailer().get_ref(b"Root") {
Some(root_ref) => match resolver.resolve(root_ref.0, root_ref.1) {
Ok(c)
if c.as_dict()
.is_some_and(|d| d.get(b"OutputIntents").is_some()) =>
{
c
}
_ => match find_catalog(resolver) {
Some(c) => c,
None => return Vec::new(),
},
},
None => match find_catalog(resolver) {
Some(c) => c,
None => return Vec::new(),
},
};
let Some(catalog_dict) = catalog_obj.as_dict() else {
return Vec::new();
};
let Some(intents_ref) = catalog_dict.get(b"OutputIntents") else {
return Vec::new();
};
let Ok(intents_obj) = resolver.deref(intents_ref) else {
return Vec::new();
};
let Some(intents_arr) = intents_obj.as_array() else {
return Vec::new();
};
let mut out = Vec::new();
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 subtype = intent_dict
.get_name(b"S")
.map(|n| n.to_vec())
.unwrap_or_else(|| b"GTS_PDFX".to_vec());
let (profile_bytes, n) = match intent_dict.get(b"DestOutputProfile") {
Some(profile_obj) => match resolver.stream_data_from_obj(profile_obj) {
Ok(bytes) if bytes.len() >= 40 && &bytes[36..40] == b"acsp" => {
let n = match &bytes[16..20] {
b"GRAY" => 1,
b"RGB " => 3,
b"CMYK" => 4,
b"Lab " => 3,
_ => 3,
};
(Some(std::sync::Arc::new(bytes)), n)
}
_ => (None, 0),
},
None => (None, 0),
};
let get_string = |key: &[u8]| -> Option<Vec<u8>> {
intent_dict.get(key).and_then(|o| match resolver.deref(o) {
Ok(PdfObj::Str(s)) => Some(s),
_ => None,
})
};
out.push(OutputIntentRecord {
subtype,
output_condition_identifier: get_string(b"OutputConditionIdentifier"),
output_condition: get_string(b"OutputCondition"),
registry_name: get_string(b"RegistryName"),
info: get_string(b"Info"),
dest_output_profile: profile_bytes,
n,
});
}
out
}
pub(crate) 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,
visibility,
} => {
eprintln!(
"{indent}[{i}] OcgGroup vis={:?} children={}",
visibility,
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_pdf_with_outline() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.4\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
push_obj(
&mut pdf,
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>\nendobj\n",
);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
);
push_obj(
&mut pdf,
b"4 0 obj\n<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count 3 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"5 0 obj\n<< /Title (Chapter 1) /Parent 4 0 R /First 6 0 R /Last 7 0 R \
/Count 2 /Dest [3 0 R /Fit] /F 2 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"6 0 obj\n<< /Title (Section 1.1) /Parent 5 0 R /Next 7 0 R \
/Dest [3 0 R /XYZ 72 700 1.0] /C [0.2 0.3 0.4] >>\nendobj\n",
);
push_obj(
&mut pdf,
b"7 0 obj\n<< /Title (Section 1.2) /Parent 5 0 R /Prev 6 0 R \
/A << /S /URI /URI (https://example.com) >> /F 1 >>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 8\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 8 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
#[test]
fn outline_basic_tree() {
let pdf = build_pdf_with_outline();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let outline = doc.outline();
assert_eq!(outline.len(), 1, "expected one top-level entry");
let chapter = &outline[0];
assert_eq!(chapter.title, "Chapter 1");
assert!(chapter.open, "Chapter 1 has /Count 2 (positive = open)");
assert!(chapter.style.bold);
assert!(!chapter.style.italic);
assert_eq!(chapter.children.len(), 2);
let s11 = &chapter.children[0];
assert_eq!(s11.title, "Section 1.1");
assert!(s11.action.is_none());
match &s11.destination {
Some(crate::Destination::PageView { page, view }) => {
assert_eq!(*page, Some(0));
assert!(matches!(view, crate::ViewSpec::Xyz { .. }));
}
other => panic!("expected PageView destination, got {other:?}"),
}
assert_eq!(s11.color, Some([0.2, 0.3, 0.4]));
let s12 = &chapter.children[1];
assert_eq!(s12.title, "Section 1.2");
assert!(s12.style.italic && !s12.style.bold);
match &s12.action {
Some(crate::Action::Uri { uri, is_map }) => {
assert_eq!(uri, "https://example.com");
assert!(!is_map);
}
other => panic!("expected URI action, got {other:?}"),
}
}
#[test]
fn outline_caches_across_calls() {
let pdf = build_pdf_with_outline();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let a = doc.outline();
let b = doc.outline();
assert!(std::ptr::eq(a, b), "outline() must be cached");
}
#[test]
fn outline_empty_when_absent() {
let pdf = build_minimal_pdf();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
assert!(doc.outline().is_empty());
}
fn build_pdf_with_legacy_dests() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.4\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
push_obj(
&mut pdf,
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Dests 4 0 R >>\nendobj\n",
);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
);
push_obj(
&mut pdf,
b"4 0 obj\n<< /Intro [3 0 R /Fit] /Glossary [3 0 R /XYZ 100 700 1.0] >>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 5\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 5 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
fn build_pdf_with_name_tree_dests() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.4\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
push_obj(
&mut pdf,
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Names 4 0 R >>\nendobj\n",
);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
);
push_obj(&mut pdf, b"4 0 obj\n<< /Dests 5 0 R >>\nendobj\n");
push_obj(
&mut pdf,
b"5 0 obj\n<< /Names [(Alpha) [3 0 R /Fit] (Beta) [3 0 R /XYZ 50 500 0]] >>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 6\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 6 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
fn build_pdf_with_dest_conflict() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.4\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
push_obj(
&mut pdf,
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Dests 4 0 R /Names 5 0 R >>\nendobj\n",
);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
);
push_obj(&mut pdf, b"4 0 obj\n<< /Conflict [3 0 R /Fit] >>\nendobj\n");
push_obj(&mut pdf, b"5 0 obj\n<< /Dests 6 0 R >>\nendobj\n");
push_obj(
&mut pdf,
b"6 0 obj\n<< /Names [(Conflict) [3 0 R /FitB]] >>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 7\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 7 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
#[test]
fn destinations_legacy_dict() {
let pdf = build_pdf_with_legacy_dests();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let dests = doc.destinations();
assert_eq!(dests.len(), 2);
match dests.get("Intro") {
Some(crate::Destination::PageView { page, view }) => {
assert_eq!(*page, Some(0));
assert_eq!(*view, crate::ViewSpec::Fit);
}
other => panic!("expected PageView for Intro, got {other:?}"),
}
assert!(dests.contains_key("Glossary"));
}
#[test]
fn destinations_name_tree() {
let pdf = build_pdf_with_name_tree_dests();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let dests = doc.destinations();
assert_eq!(dests.len(), 2);
assert!(dests.contains_key("Alpha"));
assert!(dests.contains_key("Beta"));
}
#[test]
fn destinations_legacy_overrides_name_tree() {
let pdf = build_pdf_with_dest_conflict();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let dests = doc.destinations();
assert_eq!(dests.len(), 1);
match dests.get("Conflict") {
Some(crate::Destination::PageView { view, .. }) => {
assert_eq!(
*view,
crate::ViewSpec::Fit,
"legacy /Dests must override /Names /Dests"
);
}
other => panic!("expected PageView, got {other:?}"),
}
}
#[test]
fn destinations_caches_across_calls() {
let pdf = build_pdf_with_legacy_dests();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let a = doc.destinations();
let b = doc.destinations();
assert!(std::ptr::eq(a, b), "destinations() must be cached");
}
fn build_pdf_with_annotations() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.4\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
push_obj(
&mut pdf,
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >>\nendobj\n",
);
push_obj(
&mut pdf,
b"4 0 obj\n<< /Type /Annot /Subtype /Link /Rect [72 720 540 740] \
/Border [0 0 1] \
/A << /S /URI /URI (https://example.com) >> >>\nendobj\n",
);
push_obj(
&mut pdf,
b"5 0 obj\n<< /Type /Annot /Subtype /Text /Rect [100 600 120 620] \
/Contents (A note) /Open true /Name /Comment /T (Scott) \
/M (D:20260427120000Z) >>\nendobj\n",
);
push_obj(
&mut pdf,
b"6 0 obj\n<< /Type /Annot /Subtype /Highlight /Rect [72 500 300 520] \
/QuadPoints [72 520 300 520 72 500 300 500] \
/C [1.0 0.95 0.0] >>\nendobj\n",
);
push_obj(
&mut pdf,
b"7 0 obj\n<< /Type /Annot /Subtype /Square /Rect [200 400 300 450] \
/IC [0.0 0.5 1.0] /C [0.0 0.0 0.0] /F 4 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"8 0 obj\n<< /Type /Annot /Subtype /FreeText /Rect [72 300 300 350] \
/Contents (Visible text) /DA (/Helv 10 Tf 0 g) /Q 1 \
/IT /FreeTextCallout >>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 9\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 9 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
#[test]
fn page_annotations_basic_subtypes() {
let pdf = build_pdf_with_annotations();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let annots = doc.page_annotations(0).unwrap();
assert_eq!(annots.len(), 5);
let link = &annots[0];
assert_eq!(link.kind, crate::AnnotationKind::Link);
assert_eq!(link.rect, [72.0, 720.0, 540.0, 740.0]);
match &link.kind_data {
crate::AnnotationKindData::Link(l) => match &l.action {
Some(crate::Action::Uri { uri, .. }) => {
assert_eq!(uri, "https://example.com");
}
other => panic!("expected Uri action, got {other:?}"),
},
other => panic!("expected Link kind data, got {other:?}"),
}
let text = &annots[1];
assert_eq!(text.kind, crate::AnnotationKind::Text);
assert_eq!(text.contents.as_deref(), Some("A note"));
assert_eq!(text.title.as_deref(), Some("Scott"));
match &text.kind_data {
crate::AnnotationKindData::Text(t) => {
assert!(t.open);
assert_eq!(t.icon.as_deref(), Some("Comment"));
}
_ => panic!("expected Text kind"),
}
assert!(matches!(
text.modified,
Some(crate::AnnotationDate::Date(_))
));
let hl = &annots[2];
assert_eq!(hl.kind, crate::AnnotationKind::Highlight);
assert_eq!(
hl.color,
Some(crate::AnnotationColor::Rgb([1.0, 0.95, 0.0]))
);
match &hl.kind_data {
crate::AnnotationKindData::Markup(m) => {
assert_eq!(m.quad_points.len(), 1);
}
_ => panic!("expected Markup kind"),
}
let sq = &annots[3];
assert_eq!(sq.kind, crate::AnnotationKind::Square);
assert!(sq.flags.print);
match &sq.kind_data {
crate::AnnotationKindData::Shape(s) => {
assert_eq!(
s.interior_color,
Some(crate::AnnotationColor::Rgb([0.0, 0.5, 1.0]))
);
}
_ => panic!("expected Shape kind"),
}
let ft = &annots[4];
assert_eq!(ft.kind, crate::AnnotationKind::FreeText);
match &ft.kind_data {
crate::AnnotationKindData::FreeText(f) => {
assert_eq!(f.default_appearance.as_deref(), Some("/Helv 10 Tf 0 g"));
assert_eq!(f.quadding, 1);
assert_eq!(f.intent.as_deref(), Some("FreeTextCallout"));
}
_ => panic!("expected FreeText kind"),
}
}
#[test]
fn page_annotations_caches_per_page() {
let pdf = build_pdf_with_annotations();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let a = doc.page_annotations(0).unwrap();
let b = doc.page_annotations(0).unwrap();
assert!(std::ptr::eq(a, b), "page_annotations(0) must be cached");
}
#[test]
fn page_annotations_out_of_range() {
let pdf = build_pdf_with_annotations();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
assert!(doc.page_annotations(99).is_err());
}
#[test]
fn page_annotations_empty_when_absent() {
let pdf = build_minimal_pdf();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let annots = doc.page_annotations(0).unwrap();
assert!(annots.is_empty());
}
fn build_pdf_with_form() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.4\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
push_obj(
&mut pdf,
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n",
);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Annots [5 0 R 6 0 R 9 0 R 10 0 R 12 0 R 14 0 R 15 0 R] >>\nendobj\n",
);
push_obj(
&mut pdf,
b"4 0 obj\n<< /Fields [5 0 R 6 0 R 7 0 R 11 0 R 13 0 R] \
/NeedAppearances true /SigFlags 1 \
/CO [(name)] /DA (/Helv 12 Tf 0 g) /Q 0 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"5 0 obj\n<< /T (name) /TU (Full Name) /FT /Tx /Ff 0 \
/MaxLen 50 /V (Scott) /DV () \
/Subtype /Widget /Rect [72 720 300 740] /Type /Annot >>\nendobj\n",
);
push_obj(
&mut pdf,
b"6 0 obj\n<< /T (agree) /FT /Btn /Ff 0 /V /Yes \
/Subtype /Widget /Rect [72 700 90 718] /Type /Annot >>\nendobj\n",
);
push_obj(
&mut pdf,
b"7 0 obj\n<< /T (color) /FT /Btn /Ff 49152 /V /Red \
/Kids [9 0 R 10 0 R] /Opt [(Red) (Blue)] >>\nendobj\n",
);
push_obj(
&mut pdf,
b"9 0 obj\n<< /Parent 7 0 R /Subtype /Widget /Type /Annot \
/Rect [72 680 90 698] /AS /Red >>\nendobj\n",
);
push_obj(
&mut pdf,
b"10 0 obj\n<< /Parent 7 0 R /Subtype /Widget /Type /Annot \
/Rect [100 680 118 698] /AS /Off >>\nendobj\n",
);
push_obj(
&mut pdf,
b"11 0 obj\n<< /T (country) /FT /Ch /Ff 131072 /V (US) \
/Opt [[(US) (United States)] [(GB) (United Kingdom)]] \
/Subtype /Widget /Rect [72 660 200 678] /Type /Annot >>\nendobj\n",
);
push_obj(
&mut pdf,
b"13 0 obj\n<< /T (shipping) /Kids [14 0 R 15 0 R] >>\nendobj\n",
);
push_obj(
&mut pdf,
b"14 0 obj\n<< /T (street) /Parent 13 0 R /FT /Tx /V (123 Main) \
/Subtype /Widget /Rect [72 640 300 658] /Type /Annot >>\nendobj\n",
);
push_obj(
&mut pdf,
b"15 0 obj\n<< /T (zip) /Parent 13 0 R /FT /Tx /V (12345) \
/Subtype /Widget /Rect [72 620 200 638] /Type /Annot >>\nendobj\n",
);
let xref_offset = pdf.len();
let real_offsets: Vec<usize> = offsets;
let mut entries: Vec<Option<usize>> = vec![None; 16];
let declared = [1u32, 2, 3, 4, 5, 6, 7, 9, 10, 11, 13, 14, 15];
for (i, &n) in declared.iter().enumerate() {
entries[n as usize] = Some(real_offsets[i]);
}
pdf.extend(b"xref\n0 16\n");
pdf.extend(b"0000000000 65535 f\r\n");
for entry in entries.iter().skip(1) {
match entry {
Some(off) => pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes()),
None => pdf.extend(b"0000000000 65535 f\r\n"),
}
}
pdf.extend(b"trailer\n<< /Size 16 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
#[test]
fn form_basic_field_tree() {
let pdf = build_pdf_with_form();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let form = doc.form().expect("AcroForm should be present");
assert!(form.need_appearances);
assert!(form.sig_flags.signatures_exist);
assert!(!form.sig_flags.append_only);
assert_eq!(form.calculation_order, vec!["name".to_string()]);
assert_eq!(form.default_appearance.as_deref(), Some("/Helv 12 Tf 0 g"));
assert_eq!(form.fields.len(), 5);
let name = &form.fields[0];
assert_eq!(name.name, "name");
assert_eq!(name.alternate_name.as_deref(), Some("Full Name"));
match &name.kind {
crate::FieldKind::Text(t) => {
assert_eq!(t.max_length, Some(50));
assert!(!t.multiline && !t.password);
}
_ => panic!("name should be a Text field"),
}
assert_eq!(name.value, crate::FieldValue::Text("Scott".to_string()));
assert_eq!(name.widget_obj_nums.len(), 1);
let agree = &form.fields[1];
match &agree.kind {
crate::FieldKind::Button(b) => {
assert_eq!(b.button_type, crate::ButtonType::Checkbox);
}
_ => panic!("agree should be a Button"),
}
assert_eq!(agree.value, crate::FieldValue::Name("Yes".to_string()));
let color = &form.fields[2];
assert_eq!(color.name, "color");
match &color.kind {
crate::FieldKind::Button(b) => {
assert_eq!(b.button_type, crate::ButtonType::Radio);
assert!(b.no_toggle_to_off);
assert_eq!(b.options, vec!["Red".to_string(), "Blue".to_string()]);
}
_ => panic!("color should be a Radio group"),
}
assert_eq!(color.widget_obj_nums.len(), 2);
assert!(
color.children.is_empty(),
"widget /Kids should not become children"
);
let country = &form.fields[3];
match &country.kind {
crate::FieldKind::Choice(c) => {
assert!(c.combo);
assert_eq!(c.options.len(), 2);
assert_eq!(c.options[0].export, "US");
assert_eq!(c.options[0].display, "United States");
}
_ => panic!("country should be a Choice"),
}
let shipping = &form.fields[4];
assert_eq!(shipping.name, "shipping");
assert!(matches!(shipping.kind, crate::FieldKind::Container));
assert_eq!(shipping.children.len(), 2);
assert_eq!(shipping.children[0].name, "shipping.street");
assert_eq!(shipping.children[1].name, "shipping.zip");
assert_eq!(
shipping.children[0].value,
crate::FieldValue::Text("123 Main".to_string())
);
}
#[test]
fn form_caches_across_calls() {
let pdf = build_pdf_with_form();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let a = doc.form().unwrap();
let b = doc.form().unwrap();
assert!(std::ptr::eq(a, b), "form() must be cached");
}
#[test]
fn form_absent_returns_none() {
let pdf = build_minimal_pdf();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
assert!(doc.form().is_none());
}
fn build_pdf_with_page_boxes() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.4\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
push_obj(
&mut pdf,
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R \
/MediaBox [0 0 612 792] \
/CropBox [10 10 602 782] \
/BleedBox [5 5 607 787] \
/TrimBox [20 20 592 772] \
/ArtBox [30 30 582 762] \
/Rotate 90 /UserUnit 1.5 /Dur 5.0 \
/Trans << /S /Wipe >> /AA << /O 5 0 R >> >>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 4\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).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
}
#[test]
fn page_boxes_full_set() {
let pdf = build_pdf_with_page_boxes();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let pb = doc.page_boxes(0).unwrap();
assert_eq!(pb.media_box, [0.0, 0.0, 612.0, 792.0]);
assert_eq!(pb.crop_box, Some([10.0, 10.0, 602.0, 782.0]));
assert_eq!(pb.bleed_box, Some([5.0, 5.0, 607.0, 787.0]));
assert_eq!(pb.trim_box, Some([20.0, 20.0, 592.0, 772.0]));
assert_eq!(pb.art_box, Some([30.0, 30.0, 582.0, 762.0]));
assert_eq!(pb.rotate, 90);
assert_eq!(pb.user_unit, 1.5);
assert_eq!(pb.duration, Some(5.0));
assert!(pb.has_transition);
assert!(pb.has_additional_actions);
}
#[test]
fn page_boxes_minimal_defaults() {
let pdf = build_minimal_pdf();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let pb = doc.page_boxes(0).unwrap();
assert_eq!(pb.media_box, [0.0, 0.0, 612.0, 792.0]);
assert!(pb.crop_box.is_none());
assert!(pb.bleed_box.is_none());
assert!(pb.trim_box.is_none());
assert!(pb.art_box.is_none());
assert_eq!(pb.rotate, 0);
assert_eq!(pb.user_unit, 1.0);
assert!(pb.duration.is_none());
assert!(!pb.has_transition);
assert!(!pb.has_additional_actions);
}
#[test]
fn page_boxes_out_of_range() {
let pdf = build_minimal_pdf();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
assert!(doc.page_boxes(99).is_err());
}
fn build_pdf_with_embedded_file() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.4\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
push_obj(
&mut pdf,
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Names 4 0 R >>\nendobj\n",
);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
);
push_obj(&mut pdf, b"4 0 obj\n<< /EmbeddedFiles 5 0 R >>\nendobj\n");
push_obj(
&mut pdf,
b"5 0 obj\n<< /Names [(data.csv) 6 0 R] >>\nendobj\n",
);
push_obj(
&mut pdf,
b"6 0 obj\n<< /Type /Filespec /F (data.csv) /UF (data.csv) \
/Desc (Sample CSV) /AFRelationship /Data \
/EF << /F 7 0 R /UF 7 0 R >> >>\nendobj\n",
);
let payload = b"id,name\n1,a\n";
let stream_header = b"7 0 obj\n<< /Type /EmbeddedFile /Subtype /text#2Fcsv \
/Length 12 /Params << /Size 12 >> >>\nstream\n";
offsets.push(pdf.len());
pdf.extend(stream_header);
pdf.extend(payload);
pdf.extend(b"\nendstream\nendobj\n");
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 8\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 8 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
#[test]
fn embedded_files_basic() {
let pdf = build_pdf_with_embedded_file();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let map = doc.embedded_files();
assert_eq!(map.len(), 1);
let ef = map.get("data.csv").expect("data.csv missing");
assert_eq!(ef.name, "data.csv");
assert_eq!(ef.filename.as_deref(), Some("data.csv"));
assert_eq!(ef.unicode_filename.as_deref(), Some("data.csv"));
assert_eq!(ef.description.as_deref(), Some("Sample CSV"));
assert_eq!(ef.relationship, Some(crate::AfRelationship::Data));
assert_eq!(ef.mime_type.as_deref(), Some("text/csv"));
assert_eq!(ef.size, Some(12));
let bytes = doc.embedded_file_bytes("data.csv").unwrap();
assert_eq!(&bytes[..], b"id,name\n1,a\n");
}
#[test]
fn embedded_files_caches_across_calls() {
let pdf = build_pdf_with_embedded_file();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let a = doc.embedded_files();
let b = doc.embedded_files();
assert!(std::ptr::eq(a, b), "embedded_files() must be cached");
}
fn build_pdf_with_cyclic_outline() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.4\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
push_obj(
&mut pdf,
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>\nendobj\n",
);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
);
push_obj(
&mut pdf,
b"4 0 obj\n<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"5 0 obj\n<< /Title (Loop) /Parent 4 0 R /Next 5 0 R >>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 6\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 6 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
#[test]
fn warning_emitted_for_outline_cycle() {
let pdf = build_pdf_with_cyclic_outline();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let outline = doc.outline();
assert_eq!(outline.len(), 1);
let warnings = doc.parse_warnings();
assert!(
warnings
.iter()
.any(|w| matches!(w.phase, crate::ParsePhase::Outline)
&& w.severity == crate::Severity::Warning
&& w.message.contains("cycle")),
"expected outline cycle warning, got: {:?}",
warnings.iter().collect::<Vec<_>>()
);
}
fn build_pdf_with_rectless_annot() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.4\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
push_obj(
&mut pdf,
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Annots [4 0 R] >>\nendobj\n",
);
push_obj(
&mut pdf,
b"4 0 obj\n<< /Type /Annot /Subtype /Text /Contents (no rect) >>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 5\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 5 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
#[test]
fn warning_emitted_for_rectless_annotation() {
let pdf = build_pdf_with_rectless_annot();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let annots = doc.page_annotations(0).unwrap();
assert_eq!(annots.len(), 0);
let warnings = doc.parse_warnings();
assert!(
warnings.iter().any(
|w| matches!(w.phase, crate::ParsePhase::Annotations { page: 0 })
&& w.message.contains("/Rect")
),
"expected /Rect warning, got: {:?}",
warnings.iter().collect::<Vec<_>>()
);
}
#[test]
fn parse_warnings_empty_for_clean_document() {
let pdf = build_minimal_pdf();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let _ = doc.metadata();
let _ = doc.viewer_preferences();
let _ = doc.outline();
let _ = doc.destinations();
let _ = doc.page_annotations(0).unwrap();
let _ = doc.form();
let _ = doc.embedded_files();
let _ = doc.page_boxes(0).unwrap();
let warnings = doc.parse_warnings();
assert_eq!(
warnings.len(),
0,
"got: {:?}",
warnings.iter().collect::<Vec<_>>()
);
}
#[test]
fn embedded_files_empty_when_absent() {
let pdf = build_minimal_pdf();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
assert!(doc.embedded_files().is_empty());
assert!(doc.embedded_file_bytes("missing").is_err());
}
#[test]
fn form_widgets_appear_in_page_annotations() {
let pdf = build_pdf_with_form();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let form = doc.form().unwrap();
let annots = doc.page_annotations(0).unwrap();
let widget_annot_subtypes: Vec<_> = annots
.iter()
.filter(|a| a.kind == crate::AnnotationKind::Widget)
.collect();
assert!(
!widget_annot_subtypes.is_empty(),
"expected widget annotations on page"
);
let color_field = form.fields.iter().find(|f| f.name == "color").unwrap();
assert_eq!(color_field.widget_obj_nums.len(), 2);
}
#[test]
fn resolve_named_destination_returns_dest() {
let pdf = build_pdf_with_legacy_dests();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let d = doc.resolve_named_destination("Intro").unwrap();
match d {
crate::Destination::PageView { page, view } => {
assert_eq!(page, Some(0));
assert_eq!(view, crate::ViewSpec::Fit);
}
_ => panic!("expected PageView"),
}
assert!(doc.resolve_named_destination("MissingName").is_none());
}
fn build_pdf_with_layers() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.6\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
push_obj(
&mut pdf,
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << \
/OCGs [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] \
/D << /Order [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] \
/OFF [9 0 R] /Locked [6 0 R] >> \
>> >>\nendobj\n",
);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
);
push_obj(&mut pdf, b"4 0 obj\nnull\nendobj\n");
push_obj(
&mut pdf,
b"5 0 obj\n<< /Type /OCG /Name (Background) >>\nendobj\n",
);
let mut obj6 = Vec::new();
obj6.extend(b"6 0 obj\n<< /Type /OCG ");
obj6.extend(b"/Name <FEFF005400650073007400200394> ");
obj6.extend(b"/Intent [/View /Design] ");
obj6.extend(b"/Usage << ");
obj6.extend(b"/View << /ViewState /ON >> ");
obj6.extend(b"/Print << /PrintState /OFF /Subtype /Watermark >> ");
obj6.extend(b"/Export << /ExportState /ON >> ");
obj6.extend(b"/Zoom << /min 0.5 /max 4.0 >> ");
obj6.extend(b"/Language << /Lang (en-US) /Preferred /ON >> ");
obj6.extend(b"/User << /Type /Ind /Name (alice) >> ");
obj6.extend(b"/PageElement << /Subtype /HF >> ");
obj6.extend(b"/CreatorInfo << /Creator (CADtool) /Subtype /Technical >> ");
obj6.extend(b">> ");
obj6.extend(b">>\nendobj\n");
push_obj(&mut pdf, &obj6);
push_obj(
&mut pdf,
b"7 0 obj\n<< /Type /OCG /Name (DesignLayer) /Intent [/Design] >>\nendobj\n",
);
push_obj(
&mut pdf,
b"8 0 obj\n<< /Type /OCG /Name (Custom) /Intent /Custom >>\nendobj\n",
);
push_obj(
&mut pdf,
b"9 0 obj\n<< /Type /OCG /Name (HiddenLayer) \
/CreatorInfo << /Creator (Inkscape) /Subtype /Artwork >> \
/Usage << /User << /Type /Org /Name [(group-a) (group-b)] >> >> \
>>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 10\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 10 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
#[test]
fn layers_basic_enumeration() {
let pdf = build_pdf_with_layers();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let layers = doc.layers();
assert_eq!(layers.len(), 5, "expected 5 OCGs, got {}", layers.len());
assert!(
layers
.iter()
.find(|l| l.ocg_id == 5)
.unwrap()
.default_visible
);
assert!(
layers
.iter()
.find(|l| l.ocg_id == 6)
.unwrap()
.default_visible
);
assert!(
!layers
.iter()
.find(|l| l.ocg_id == 9)
.unwrap()
.default_visible
);
assert!(layers.iter().find(|l| l.ocg_id == 6).unwrap().locked);
assert!(!layers.iter().find(|l| l.ocg_id == 5).unwrap().locked);
assert!(!layers.iter().find(|l| l.ocg_id == 9).unwrap().locked);
}
#[test]
fn layers_name_decoding() {
let pdf = build_pdf_with_layers();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let bg = doc.layer(5).unwrap();
assert_eq!(bg.name, "Background");
let utf16 = doc.layer(6).unwrap();
assert_eq!(utf16.name, "Test \u{0394}");
}
#[test]
fn layers_intent_variants() {
let pdf = build_pdf_with_layers();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
assert_eq!(doc.layer(5).unwrap().intent, LayerIntent::View);
match &doc.layer(6).unwrap().intent {
LayerIntent::Multiple(names) => {
assert_eq!(names.len(), 2);
assert_eq!(names[0], "View");
assert_eq!(names[1], "Design");
}
other => panic!("expected Multiple, got {other:?}"),
}
assert_eq!(doc.layer(7).unwrap().intent, LayerIntent::Design);
match &doc.layer(8).unwrap().intent {
LayerIntent::Other(s) => assert_eq!(s, "Custom"),
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn layers_full_usage_dict() {
let pdf = build_pdf_with_layers();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let l = doc.layer(6).unwrap();
let view = l.usage.view.expect("view sub-dict");
assert_eq!(view.state, UsageState::On);
let print = l.usage.print.as_ref().expect("print sub-dict");
assert_eq!(print.state, UsageState::Off);
assert_eq!(print.subtype.as_deref(), Some("Watermark"));
let export = l.usage.export.expect("export sub-dict");
assert_eq!(export.state, UsageState::On);
let zoom = l.usage.zoom.expect("zoom sub-dict");
assert_eq!(zoom.min, Some(0.5));
assert_eq!(zoom.max, Some(4.0));
let lang = l.usage.language.as_ref().expect("language sub-dict");
assert_eq!(lang.lang, "en-US");
assert!(lang.preferred);
let user = l.usage.user.as_ref().expect("user sub-dict");
assert_eq!(user.user_type.as_deref(), Some("Ind"));
assert_eq!(user.names, vec!["alice".to_string()]);
assert_eq!(l.usage.page_element, Some(PageElementSubtype::HeaderFooter));
let ci = l.usage.creator_info.as_ref().expect("creator_info");
assert_eq!(ci.creator, "CADtool");
assert_eq!(ci.subtype.as_deref(), Some("Technical"));
}
#[test]
fn layers_creator_info_on_ocg() {
let pdf = build_pdf_with_layers();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let hidden = doc.layer(9).unwrap();
let ci = hidden.creator_info.as_ref().expect("creator_info");
assert_eq!(ci.creator, "Inkscape");
assert_eq!(ci.subtype.as_deref(), Some("Artwork"));
let user = hidden.usage.user.as_ref().expect("user sub-dict");
assert_eq!(user.user_type.as_deref(), Some("Org"));
assert_eq!(
user.names,
vec!["group-a".to_string(), "group-b".to_string()]
);
}
#[test]
fn layers_empty_when_no_oc_properties() {
let pdf = build_minimal_pdf();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
assert!(doc.layers().is_empty());
assert!(doc.layer(42).is_none());
}
#[test]
fn layers_caches_across_calls() {
let pdf = build_pdf_with_layers();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let first = doc.layers().as_ptr();
let second = doc.layers().as_ptr();
assert_eq!(first, second, "layers() should return a cached slice");
}
fn build_pdf_with_layer_hierarchy() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.6\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
let mut cat = Vec::new();
cat.extend(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << ");
cat.extend(b"/OCGs [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] ");
cat.extend(b"/D << /Name (Default) /Creator (TestApp) ");
cat.extend(b"/BaseState /OFF /ON [5 0 R 7 0 R] /OFF [9 0 R] ");
cat.extend(b"/Locked [6 0 R] ");
cat.extend(b"/Intent /View ");
cat.extend(b"/ListMode /VisiblePages ");
cat.extend(b"/Order [5 0 R (Backgrounds) [6 0 R 7 0 R] 8 0 R [9 0 R] [5 0 R]] ");
cat.extend(b"/RBGroups [[6 0 R 7 0 R]] ");
cat.extend(b"/AS [<< /Event /Print /Category [/Print] /OCGs [9 0 R] >>] ");
cat.extend(b">> ");
cat.extend(b"/Configs [<< /Name (Alternate) /Creator (Other) ");
cat.extend(b"/BaseState /ON /OFF [5 0 R] /Intent /Design ");
cat.extend(b"/Order [6 0 R 7 0 R] >>] ");
cat.extend(b">> >>\nendobj\n");
push_obj(&mut pdf, &cat);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
);
push_obj(&mut pdf, b"4 0 obj\nnull\nendobj\n");
for (n, name) in (5u32..=9).zip(["L5", "L6", "L7", "L8", "L9"]) {
let body = format!("{n} 0 obj\n<< /Type /OCG /Name ({name}) >>\nendobj\n");
push_obj(&mut pdf, body.as_bytes());
}
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 10\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 10 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
#[test]
fn configurations_default_and_alternate() {
let pdf = build_pdf_with_layer_hierarchy();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let configs = doc.configurations();
assert_eq!(configs.len(), 2, "default + one alternate");
let d = doc.default_configuration().unwrap();
assert_eq!(d.index, 0);
assert_eq!(d.name.as_deref(), Some("Default"));
assert_eq!(d.creator.as_deref(), Some("TestApp"));
assert_eq!(d.base_state, BaseState::Off);
assert_eq!(d.on, vec![5, 7]);
assert_eq!(d.off, vec![9]);
assert_eq!(d.locked, vec![6]);
assert_eq!(d.list_mode, ListMode::VisiblePages);
assert_eq!(d.intent, LayerIntent::View);
let alt = doc.configuration(1).unwrap();
assert_eq!(alt.index, 1);
assert_eq!(alt.name.as_deref(), Some("Alternate"));
assert_eq!(alt.creator.as_deref(), Some("Other"));
assert_eq!(alt.base_state, BaseState::On);
assert_eq!(alt.off, vec![5]);
assert_eq!(alt.intent, LayerIntent::Design);
}
#[test]
fn order_mixes_flat_labelled_header_and_anonymous_sections() {
let pdf = build_pdf_with_layer_hierarchy();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let tree = doc.layer_tree();
assert_eq!(tree.nodes.len(), 4, "expected 4 top-level nodes");
match &tree.nodes[0] {
LayerTreeNode::Layer(id) => assert_eq!(*id, 5),
other => panic!("nodes[0]: expected Layer(5), got {other:?}"),
}
match &tree.nodes[1] {
LayerTreeNode::Section {
label,
header_layer,
children,
} => {
assert_eq!(label.as_deref(), Some("Backgrounds"));
assert!(header_layer.is_none());
assert_eq!(children.len(), 2);
if let LayerTreeNode::Layer(id) = &children[0] {
assert_eq!(*id, 6);
} else {
panic!("children[0] not a Layer");
}
if let LayerTreeNode::Layer(id) = &children[1] {
assert_eq!(*id, 7);
} else {
panic!("children[1] not a Layer");
}
}
other => panic!("nodes[1]: expected labelled Section, got {other:?}"),
}
match &tree.nodes[2] {
LayerTreeNode::Section {
label,
header_layer,
children,
} => {
assert!(label.is_none());
assert_eq!(*header_layer, Some(8));
assert_eq!(children.len(), 1);
if let LayerTreeNode::Layer(id) = &children[0] {
assert_eq!(*id, 9);
} else {
panic!("children[0] not a Layer");
}
}
other => panic!("nodes[2]: expected header-layer Section, got {other:?}"),
}
match &tree.nodes[3] {
LayerTreeNode::Section {
label,
header_layer,
children,
} => {
assert!(label.is_none());
assert!(header_layer.is_none());
assert_eq!(children.len(), 1);
if let LayerTreeNode::Layer(id) = &children[0] {
assert_eq!(*id, 5);
} else {
panic!("children[0] not a Layer");
}
}
other => panic!("nodes[3]: expected anonymous Section, got {other:?}"),
}
}
#[test]
fn auto_state_rules_parsed() {
let pdf = build_pdf_with_layer_hierarchy();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let d = doc.default_configuration().unwrap();
assert_eq!(d.auto_state.len(), 1);
let rule = &d.auto_state[0];
assert_eq!(rule.event, AutoStateEvent::Print);
assert_eq!(rule.categories, vec!["Print".to_string()]);
assert_eq!(rule.ocgs, vec![9]);
}
#[test]
fn rb_groups_parsed() {
let pdf = build_pdf_with_layer_hierarchy();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let d = doc.default_configuration().unwrap();
assert_eq!(d.rb_groups, vec![vec![6, 7]]);
}
#[test]
fn layer_tree_alternate_config_differs() {
let pdf = build_pdf_with_layer_hierarchy();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let alt = doc.configuration(1).unwrap();
assert_eq!(alt.order.nodes.len(), 2);
assert!(matches!(alt.order.nodes[0], LayerTreeNode::Layer(6)));
assert!(matches!(alt.order.nodes[1], LayerTreeNode::Layer(7)));
}
#[test]
fn configurations_empty_when_no_oc_properties() {
let pdf = build_minimal_pdf();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
assert!(doc.configurations().is_empty());
assert!(doc.default_configuration().is_none());
assert!(doc.configuration(0).is_none());
assert!(doc.layer_tree().nodes.is_empty());
}
#[test]
fn configurations_caches_across_calls() {
let pdf = build_pdf_with_layer_hierarchy();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let first = doc.configurations().as_ptr();
let second = doc.configurations().as_ptr();
assert_eq!(first, second);
}
#[test]
fn order_with_dangling_string_emits_warning() {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.6\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
push_obj(
&mut pdf,
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << \
/OCGs [4 0 R] /D << /Order [(Orphan) 4 0 R] >> >> >>\nendobj\n",
);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
);
push_obj(
&mut pdf,
b"4 0 obj\n<< /Type /OCG /Name (Solo) >>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 5\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 5 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let tree = doc.layer_tree();
assert_eq!(tree.nodes.len(), 1);
assert!(matches!(tree.nodes[0], LayerTreeNode::Layer(4)));
let warnings = doc.parse_warnings();
assert!(
warnings
.iter()
.any(|w| matches!(w.phase, ParsePhase::Layers) && w.message.contains("Orphan")),
"expected a Layers warning about the orphan string, got {warnings:?}"
);
}
fn build_pdf_with_layered_content() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.6\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
push_obj(
&mut pdf,
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << \
/OCGs [5 0 R] /D << /Order [5 0 R] >> >> >>\nendobj\n",
);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
/Contents 4 0 R /Resources << /Properties << /OC1 5 0 R >> >> >>\nendobj\n",
);
let stream = b"q 1 0 0 rg 0 0 50 50 re f Q\n\
/OC /OC1 BDC q 0 0 1 rg 50 50 50 50 re f Q EMC";
let stream_obj = format!(
"4 0 obj\n<< /Length {} >>\nstream\n{}\nendstream\nendobj\n",
stream.len(),
std::str::from_utf8(stream).unwrap()
);
push_obj(&mut pdf, stream_obj.as_bytes());
push_obj(
&mut pdf,
b"5 0 obj\n<< /Type /OCG /Name (BlueLayer) >>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 6\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 6 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
fn sample_pixel(rgba: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] {
let i = (y as usize * w as usize + x as usize) * 4;
[rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]
}
#[cfg(feature = "render")]
#[test]
fn render_default_layer_set_matches_implicit_render() {
let pdf = build_pdf_with_layered_content();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let (rgba_default, w, h) = doc.render_page_to_rgba(0, 72.0).unwrap();
let (rgba_with_set, w2, h2) = doc
.render_page_to_rgba_with_layers(0, 72.0, &LayerSet::new())
.unwrap();
assert_eq!(w, w2);
assert_eq!(h, h2);
assert_eq!(
rgba_default, rgba_with_set,
"empty LayerSet must render byte-identical to plain render_page_to_rgba"
);
}
#[cfg(feature = "render")]
#[test]
fn render_layer_off_hides_layer_content() {
let pdf = build_pdf_with_layered_content();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let (rgba_on, w, _h) = doc.render_page_to_rgba(0, 72.0).unwrap();
let on_pixel = sample_pixel(&rgba_on, w, 75, 25);
assert!(
on_pixel[2] > 200 && on_pixel[0] < 50,
"expected blue layer pixel, got rgba={:?}",
on_pixel
);
let mut layers = layers::layer_set_from_document(&doc);
layers.set(5, false);
let (rgba_off, _w, _h) = doc
.render_page_to_rgba_with_layers(0, 72.0, &layers)
.unwrap();
let off_pixel = sample_pixel(&rgba_off, w, 75, 25);
assert!(
off_pixel[0] >= 250 && off_pixel[1] >= 250 && off_pixel[2] >= 250,
"expected layer-off pixel to be background white, got rgba={:?}",
off_pixel
);
let baseline = sample_pixel(&rgba_off, w, 25, 75);
assert!(
baseline[0] > 200 && baseline[1] < 50 && baseline[2] < 50,
"baseline red rect should still render, got rgba={:?}",
baseline
);
}
#[test]
fn layer_set_from_document_populates_defaults() {
let pdf = build_pdf_with_layers();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let set = layers::layer_set_from_document(&doc);
assert_eq!(set.get(5), Some(true));
assert_eq!(set.get(6), Some(true));
assert_eq!(set.get(9), Some(false));
}
fn build_pdf_with_ocmd(policy: &[u8], off: &[u32]) -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.6\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
let mut off_arr = String::from("[");
for id in off {
off_arr.push_str(&format!("{id} 0 R "));
}
off_arr.push(']');
let cat = format!(
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << \
/OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] /OFF {off_arr} >> >> >>\nendobj\n"
);
push_obj(&mut pdf, cat.as_bytes());
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
/Contents 4 0 R /Resources << /Properties << /OC1 7 0 R >> >> >>\nendobj\n",
);
let stream = b"q 1 0 0 rg 0 0 50 50 re f Q\n\
/OC /OC1 BDC q 0 0 1 rg 50 50 50 50 re f Q EMC";
let stream_obj = format!(
"4 0 obj\n<< /Length {} >>\nstream\n{}\nendstream\nendobj\n",
stream.len(),
std::str::from_utf8(stream).unwrap()
);
push_obj(&mut pdf, stream_obj.as_bytes());
push_obj(
&mut pdf,
b"5 0 obj\n<< /Type /OCG /Name (LayerA) >>\nendobj\n",
);
push_obj(
&mut pdf,
b"6 0 obj\n<< /Type /OCG /Name (LayerB) >>\nendobj\n",
);
let ocmd = format!(
"7 0 obj\n<< /Type /OCMD /OCGs [5 0 R 6 0 R] /P /{} >>\nendobj\n",
std::str::from_utf8(policy).unwrap()
);
push_obj(&mut pdf, ocmd.as_bytes());
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 8\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off_v in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off_v).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 8 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
fn ocmd_visibility(pdf: &[u8]) -> OcgVisibility {
let doc = PdfDocument::from_bytes(pdf).unwrap();
let dl = doc.render_page(0, 72.0).unwrap();
for elem in dl.elements() {
if let stet_graphics::display_list::DisplayElement::OcgGroup { visibility, .. } = elem {
return visibility.clone();
}
}
panic!("expected an OcgGroup in display list")
}
#[test]
fn ocmd_emits_membership_with_policy() {
let v = ocmd_visibility(&build_pdf_with_ocmd(b"AllOn", &[]));
match v {
OcgVisibility::Membership {
ocg_ids,
policy,
default_visible,
} => {
assert_eq!(ocg_ids, vec![5, 6]);
assert_eq!(policy, MembershipPolicy::AllOn);
assert!(default_visible);
}
other => panic!("expected Membership, got {other:?}"),
}
let v = ocmd_visibility(&build_pdf_with_ocmd(b"AnyOff", &[]));
match v {
OcgVisibility::Membership {
policy,
default_visible,
..
} => {
assert_eq!(policy, MembershipPolicy::AnyOff);
assert!(!default_visible);
}
other => panic!("expected Membership, got {other:?}"),
}
let v = ocmd_visibility(&build_pdf_with_ocmd(b"AllOff", &[5, 6]));
match v {
OcgVisibility::Membership {
policy,
default_visible,
..
} => {
assert_eq!(policy, MembershipPolicy::AllOff);
assert!(default_visible);
}
other => panic!("expected Membership, got {other:?}"),
}
}
#[test]
fn ocmd_membership_truth_table_via_layer_set() {
let pdf = build_pdf_with_ocmd(b"AllOn", &[]);
let v = ocmd_visibility(&pdf);
for a in [false, true] {
for b in [false, true] {
let mut s = LayerSet::new();
s.set(5, a);
s.set(6, b);
let expected = a && b;
assert_eq!(
s.evaluate(&v),
expected,
"AllOn(5={a}, 6={b}) expected {expected}"
);
}
}
}
fn build_pdf_with_ve_expression() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.6\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
push_obj(
&mut pdf,
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << \
/OCGs [5 0 R 6 0 R 7 0 R] /D << /Order [5 0 R 6 0 R 7 0 R] >> >> >>\nendobj\n",
);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
/Contents 4 0 R /Resources << /Properties << /OC1 8 0 R >> >> >>\nendobj\n",
);
let stream = b"/OC /OC1 BDC q 0 0 1 rg 0 0 100 100 re f Q EMC";
let stream_obj = format!(
"4 0 obj\n<< /Length {} >>\nstream\n{}\nendstream\nendobj\n",
stream.len(),
std::str::from_utf8(stream).unwrap()
);
push_obj(&mut pdf, stream_obj.as_bytes());
push_obj(&mut pdf, b"5 0 obj\n<< /Type /OCG /Name (A) >>\nendobj\n");
push_obj(&mut pdf, b"6 0 obj\n<< /Type /OCG /Name (B) >>\nendobj\n");
push_obj(&mut pdf, b"7 0 obj\n<< /Type /OCG /Name (C) >>\nendobj\n");
push_obj(
&mut pdf,
b"8 0 obj\n<< /Type /OCMD /VE [/And 5 0 R [/Or 6 0 R [/Not 7 0 R]]] >>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 9\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 9 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
#[test]
fn ve_expression_parsed_into_visibility_expr() {
let pdf = build_pdf_with_ve_expression();
let v = ocmd_visibility(&pdf);
match &v {
OcgVisibility::Expression { expr, .. } => match expr {
VisibilityExpr::And(args) => {
assert_eq!(args.len(), 2);
assert!(matches!(args[0], VisibilityExpr::Layer(5)));
match &args[1] {
VisibilityExpr::Or(or_args) => {
assert_eq!(or_args.len(), 2);
assert!(matches!(or_args[0], VisibilityExpr::Layer(6)));
match &or_args[1] {
VisibilityExpr::Not(inner) => {
assert!(matches!(**inner, VisibilityExpr::Layer(7)));
}
other => panic!("expected Not, got {other:?}"),
}
}
other => panic!("expected Or, got {other:?}"),
}
}
other => panic!("expected And, got {other:?}"),
},
other => panic!("expected Expression, got {other:?}"),
}
for a in [false, true] {
for b in [false, true] {
for c in [false, true] {
let mut s = LayerSet::new();
s.set(5, a);
s.set(6, b);
s.set(7, c);
let expected = a && (b || !c);
assert_eq!(
s.evaluate(&v),
expected,
"(a={a}, b={b}, c={c}) expected {expected}"
);
}
}
}
}
#[test]
fn malformed_ve_falls_back_to_membership() {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.6\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
push_obj(
&mut pdf,
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << \
/OCGs [5 0 R] /D << /Order [5 0 R] >> >> >>\nendobj\n",
);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
/Contents 4 0 R /Resources << /Properties << /OC1 6 0 R >> >> >>\nendobj\n",
);
let stream = b"/OC /OC1 BDC q 1 0 0 rg 0 0 100 100 re f Q EMC";
let stream_obj = format!(
"4 0 obj\n<< /Length {} >>\nstream\n{}\nendstream\nendobj\n",
stream.len(),
std::str::from_utf8(stream).unwrap()
);
push_obj(&mut pdf, stream_obj.as_bytes());
push_obj(&mut pdf, b"5 0 obj\n<< /Type /OCG /Name (X) >>\nendobj\n");
push_obj(
&mut pdf,
b"6 0 obj\n<< /Type /OCMD /VE [/Not 5 0 R 5 0 R] /OCGs [5 0 R] /P /AnyOn >>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 7\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 7 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
let v = ocmd_visibility(&pdf);
match v {
OcgVisibility::Membership {
ocg_ids, policy, ..
} => {
assert_eq!(ocg_ids, vec![5]);
assert_eq!(policy, MembershipPolicy::AnyOn);
}
other => panic!("expected fallback to Membership, got {other:?}"),
}
}
#[test]
fn layer_set_from_configuration_applies_base_state() {
let pdf = build_pdf_with_layer_hierarchy();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let d = layers::layer_set_from_configuration(&doc, 0).unwrap();
assert_eq!(d.get(5), Some(true));
assert_eq!(d.get(6), Some(false));
assert_eq!(d.get(7), Some(true));
assert_eq!(d.get(8), Some(false));
assert_eq!(d.get(9), Some(false));
let alt = layers::layer_set_from_configuration(&doc, 1).unwrap();
assert_eq!(alt.get(5), Some(false));
assert_eq!(alt.get(6), Some(true));
assert_eq!(alt.get(7), Some(true));
assert!(layers::layer_set_from_configuration(&doc, 99).is_none());
}
fn build_pdf_with_auto_state_rules() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend(b"%PDF-1.6\n");
let mut offsets: Vec<usize> = Vec::new();
let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
offsets.push(buf.len());
buf.extend(body);
};
let mut cat = Vec::new();
cat.extend(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << ");
cat.extend(b"/OCGs [5 0 R 6 0 R 7 0 R] /D << /Order [5 0 R 6 0 R 7 0 R] ");
cat.extend(b"/AS [");
cat.extend(b"<< /Event /Print /Category [/Print] /OCGs [5 0 R 6 0 R] >> ");
cat.extend(b"<< /Event /Export /Category [/Export] /OCGs [5 0 R] >>");
cat.extend(b"] ");
cat.extend(b">> >>\nendobj\n");
push_obj(&mut pdf, &cat);
push_obj(
&mut pdf,
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
);
push_obj(
&mut pdf,
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>\nendobj\n",
);
push_obj(&mut pdf, b"4 0 obj\nnull\nendobj\n");
push_obj(
&mut pdf,
b"5 0 obj\n<< /Type /OCG /Name (Watermark) \
/Usage << /Print << /PrintState /OFF /Subtype /Watermark >> \
/Export << /ExportState /OFF >> \
>> >>\nendobj\n",
);
push_obj(
&mut pdf,
b"6 0 obj\n<< /Type /OCG /Name (ScreenOnly) \
/Usage << /View << /ViewState /ON >> \
/Print << /PrintState /OFF >> \
>> >>\nendobj\n",
);
push_obj(
&mut pdf,
b"7 0 obj\n<< /Type /OCG /Name (Hint) \
/Usage << /Export << /ExportState /OFF >> >> >>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend(b"xref\n0 8\n");
pdf.extend(b"0000000000 65535 f\r\n");
for off in &offsets {
pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
}
pdf.extend(b"trailer\n<< /Size 8 /Root 1 0 R >>\n");
pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
#[test]
fn layer_set_for_view_keeps_defaults() {
let pdf = build_pdf_with_auto_state_rules();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let set = doc.layer_set_for(RenderIntent::View);
assert_eq!(set.get(5), Some(true));
assert_eq!(set.get(6), Some(true));
assert_eq!(set.get(7), Some(true));
}
#[test]
fn layer_set_for_print_applies_off_rules() {
let pdf = build_pdf_with_auto_state_rules();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let set = doc.layer_set_for(RenderIntent::Print);
assert_eq!(set.get(5), Some(false));
assert_eq!(set.get(6), Some(false));
assert_eq!(set.get(7), Some(true));
}
#[test]
fn layer_set_for_export_only_touches_listed_ocgs() {
let pdf = build_pdf_with_auto_state_rules();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let set = doc.layer_set_for(RenderIntent::Export);
assert_eq!(set.get(5), Some(false));
assert_eq!(set.get(6), Some(true));
assert_eq!(set.get(7), Some(true));
}
#[test]
fn layer_set_for_with_no_oc_properties_returns_empty() {
let pdf = build_minimal_pdf();
let doc = PdfDocument::from_bytes(&pdf).unwrap();
let set = doc.layer_set_for(RenderIntent::Print);
assert!(set.is_empty());
}
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
}
}