Skip to main content

zpdf_document/
lib.rs

1pub mod annot_appearance;
2pub mod annotation;
3mod catalog;
4pub mod embedded_files;
5pub mod font_loader;
6pub mod forms;
7pub mod optional_content;
8pub mod output_intents;
9pub mod page;
10
11pub use annotation::Annotation;
12pub use catalog::Catalog;
13pub use embedded_files::{EmbeddedFile, EmbeddedSource};
14pub use forms::{AcroForm, FieldKind, FieldValue, FormField};
15pub use optional_content::OcConfig;
16pub use output_intents::OutputIntent;
17pub use page::{PdfPage, ResourceDict};
18
19use std::sync::{Arc, OnceLock};
20use zpdf_core::{Error, ParseLimits, Result};
21use zpdf_font::FontCache;
22use zpdf_parser::PdfFile;
23
24pub struct PdfDocument {
25    file: PdfFile,
26    catalog: Catalog,
27    /// Lazily-parsed interactive form, shared across page-annotation calls so
28    /// the field-tree walk runs at most once per document.
29    acro_form: OnceLock<Option<AcroForm>>,
30}
31
32impl PdfDocument {
33    pub fn open(data: impl Into<Arc<[u8]>>) -> Result<Self> {
34        Self::open_with_limits(data, ParseLimits::default())
35    }
36
37    pub fn open_with_limits(data: impl Into<Arc<[u8]>>, limits: ParseLimits) -> Result<Self> {
38        Self::open_with_password_and_limits(data, b"", limits)
39    }
40
41    /// Open an encrypted document with a user or owner password. Returns
42    /// [`zpdf_core::Error::WrongPassword`] when the password authenticates as
43    /// neither. (A non-encrypted document opens regardless of the password.)
44    pub fn open_with_password(data: impl Into<Arc<[u8]>>, password: &[u8]) -> Result<Self> {
45        Self::open_with_password_and_limits(data, password, ParseLimits::default())
46    }
47
48    pub fn open_with_password_and_limits(
49        data: impl Into<Arc<[u8]>>,
50        password: &[u8],
51        limits: ParseLimits,
52    ) -> Result<Self> {
53        let file = PdfFile::parse_with_password_and_limits(data, password, limits)?;
54        let catalog = Catalog::from_trailer(&file)?;
55        Ok(Self {
56            file,
57            catalog,
58            acro_form: OnceLock::new(),
59        })
60    }
61
62    /// True when the document is encrypted (carries an `/Encrypt` dictionary).
63    pub fn is_encrypted(&self) -> bool {
64        self.file.is_encrypted()
65    }
66
67    pub fn page_count(&self) -> usize {
68        self.catalog.page_count
69    }
70
71    pub fn page(&self, index: usize) -> Result<PdfPage> {
72        self.catalog.get_page(&self.file, index)
73    }
74
75    pub fn file(&self) -> &PdfFile {
76        &self.file
77    }
78
79    pub fn version(&self) -> (u8, u8) {
80        (self.file.header.major, self.file.header.minor)
81    }
82
83    /// Get decoded content stream bytes for a page.
84    pub fn page_content_bytes(&self, page: &PdfPage) -> Result<Vec<u8>> {
85        let mut all_bytes = Vec::new();
86        for &content_id in &page.contents {
87            match self.file.resolve_stream_data(content_id) {
88                Ok(bytes) => {
89                    if !all_bytes.is_empty() {
90                        all_bytes.push(b'\n');
91                    }
92                    all_bytes.extend_from_slice(&bytes);
93                }
94                Err(e) => {
95                    tracing::warn!("failed to decode content stream {content_id}: {e}");
96                }
97            }
98        }
99        Ok(all_bytes)
100    }
101
102    /// Load all fonts referenced by a page.
103    pub fn load_page_fonts(&self, page: &PdfPage) -> FontCache {
104        font_loader::load_page_fonts(self.file(), page)
105    }
106
107    /// Parse a page's annotations into renderable form (/Rect, /F, the
108    /// /AS-selected appearance stream, /OC membership). Widget annotations for
109    /// interactive-form fields gain a generated appearance when the producer
110    /// left none (or set /NeedAppearances).
111    pub fn page_annotations(&self, page: &PdfPage) -> Vec<Annotation> {
112        annotation::parse_annotations(&self.file, page, self.acro_form())
113    }
114
115    /// The document's interactive form (`/AcroForm`), if any. Parsed once and
116    /// cached for the lifetime of the document.
117    pub fn acro_form(&self) -> Option<&AcroForm> {
118        self.acro_form
119            .get_or_init(|| AcroForm::parse(&self.file))
120            .as_ref()
121    }
122
123    /// The document's default optional-content configuration, if any.
124    pub fn oc_config(&self) -> Option<OcConfig> {
125        optional_content::parse_oc_config(&self.file)
126    }
127
128    /// The document-level output intents (catalog `/OutputIntents`). Empty when
129    /// the document declares none. Page-level intents (PDF 2.0) are carried on
130    /// the page and read via [`PdfDocument::page_output_intents`].
131    pub fn output_intents(&self) -> Vec<OutputIntent> {
132        output_intents::parse_output_intents(&self.file)
133    }
134
135    /// PDF 2.0 page-level `/OutputIntents`, which override the document-level
136    /// intents for that page. Empty for pre-2.0 / most documents.
137    pub fn page_output_intents<'a>(&self, page: &'a PdfPage) -> &'a [OutputIntent] {
138        &page.output_intents
139    }
140
141    /// The document's embedded files — file streams registered in the catalog's
142    /// `/Names /EmbeddedFiles` name tree (a viewer's "attachments"). Empty when
143    /// the document carries none. Pull a file's bytes with
144    /// [`PdfDocument::embedded_file_bytes`].
145    pub fn embedded_files(&self) -> Vec<EmbeddedFile> {
146        embedded_files::parse_embedded_files(&self.file)
147    }
148
149    /// Catalog-level associated files (`/Root /AF`, PDF 2.0). Each carries an
150    /// `/AFRelationship`. Per PDF 2.0 these are also listed by
151    /// [`PdfDocument::embedded_files`]; the two lists usually overlap.
152    pub fn associated_files(&self) -> Vec<EmbeddedFile> {
153        embedded_files::parse_associated_files(&self.file)
154    }
155
156    /// Page-level associated files (`/Page /AF`, PDF 2.0) for one page. `/AF` is
157    /// not inheritable, so only the leaf page dictionary is consulted.
158    pub fn page_associated_files(&self, page: &PdfPage) -> Vec<EmbeddedFile> {
159        match self
160            .file
161            .resolve(page.id)
162            .ok()
163            .and_then(|o| o.as_dict().ok().cloned())
164        {
165            Some(dict) => embedded_files::parse_page_associated_files(&self.file, &dict),
166            None => Vec::new(),
167        }
168    }
169
170    /// Decode and return the bytes of an embedded file. Routes through the
171    /// parser's filter pipeline, so it respects `ParseLimits` (max stream size).
172    /// Errors if the file specification carries no embedded stream
173    /// ([`EmbeddedFile::is_embedded`] is `false`).
174    pub fn embedded_file_bytes(&self, file: &EmbeddedFile) -> Result<Vec<u8>> {
175        match file.stream {
176            Some(id) => self.file.resolve_stream_data(id),
177            // An external file specification has nothing to extract; report the
178            // absent /EF as a missing key rather than a fake object-corruption
179            // error, so a caller can distinguish it from a decode failure.
180            None => Err(Error::MissingKey("EF".into())),
181        }
182    }
183}
184
185#[cfg(test)]
186pub(crate) mod test_util {
187    /// Build a synthetic PDF from numbered object bodies (index `i` becomes
188    /// object `i + 1`), with a correct xref table and a trailer whose /Root is
189    /// object 1. Offsets are computed, so bodies can be edited freely.
190    pub fn build_pdf(objects: &[&str]) -> Vec<u8> {
191        let mut buf = Vec::from(&b"%PDF-1.7\n"[..]);
192        let mut offsets = Vec::with_capacity(objects.len());
193        for (i, body) in objects.iter().enumerate() {
194            offsets.push(buf.len());
195            buf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
196        }
197        let xref_off = buf.len();
198        buf.extend_from_slice(
199            format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
200        );
201        for off in &offsets {
202            buf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
203        }
204        buf.extend_from_slice(
205            format!(
206                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF\n",
207                objects.len() + 1
208            )
209            .as_bytes(),
210        );
211        buf
212    }
213}