Skip to main content

zpdf_document/
lib.rs

1pub mod annot_appearance;
2pub mod annotation;
3mod catalog;
4pub mod destinations;
5pub mod doc_info;
6pub mod embedded_files;
7pub mod font_loader;
8pub mod forms;
9mod obj_util;
10pub mod optional_content;
11pub mod outline;
12pub mod output_intents;
13pub mod page;
14pub mod page_labels;
15pub mod xmp;
16
17pub use annotation::Annotation;
18pub use catalog::Catalog;
19pub use destinations::{DestView, Destination};
20pub use doc_info::DocInfo;
21pub use embedded_files::{EmbeddedFile, EmbeddedSource};
22pub use forms::{AcroForm, FieldKind, FieldValue, FormField};
23pub use optional_content::OcConfig;
24pub use outline::OutlineItem;
25pub use output_intents::OutputIntent;
26pub use page::{PdfPage, ResourceDict};
27pub use page_labels::{PageLabelStyle, PageLabels};
28pub use xmp::XmpMetadata;
29
30use std::collections::HashMap;
31use std::sync::{Arc, OnceLock};
32use zpdf_core::{Error, ParseLimits, PdfObject, Result};
33use zpdf_font::FontCache;
34use zpdf_parser::PdfFile;
35
36pub struct PdfDocument {
37    file: PdfFile,
38    catalog: Catalog,
39    /// Lazily-parsed interactive form, shared across page-annotation calls so
40    /// the field-tree walk runs at most once per document.
41    acro_form: OnceLock<Option<AcroForm>>,
42    /// Lazily-flattened named-destination map, shared across page-annotation
43    /// calls so resolving link targets never re-walks the name tree per page —
44    /// a full-document link scan stays O(pages × links + tree), not O(pages ×
45    /// tree).
46    named_dests: OnceLock<HashMap<Vec<u8>, PdfObject>>,
47}
48
49impl PdfDocument {
50    pub fn open(data: impl Into<Arc<[u8]>>) -> Result<Self> {
51        Self::open_with_limits(data, ParseLimits::default())
52    }
53
54    pub fn open_with_limits(data: impl Into<Arc<[u8]>>, limits: ParseLimits) -> Result<Self> {
55        Self::open_with_password_and_limits(data, b"", limits)
56    }
57
58    /// Open an encrypted document with a user or owner password. Returns
59    /// [`zpdf_core::Error::WrongPassword`] when the password authenticates as
60    /// neither. (A non-encrypted document opens regardless of the password.)
61    pub fn open_with_password(data: impl Into<Arc<[u8]>>, password: &[u8]) -> Result<Self> {
62        Self::open_with_password_and_limits(data, password, ParseLimits::default())
63    }
64
65    pub fn open_with_password_and_limits(
66        data: impl Into<Arc<[u8]>>,
67        password: &[u8],
68        limits: ParseLimits,
69    ) -> Result<Self> {
70        let file = PdfFile::parse_with_password_and_limits(data, password, limits)?;
71        let catalog = Catalog::from_trailer(&file)?;
72        Ok(Self {
73            file,
74            catalog,
75            acro_form: OnceLock::new(),
76            named_dests: OnceLock::new(),
77        })
78    }
79
80    /// True when the document is encrypted (carries an `/Encrypt` dictionary).
81    pub fn is_encrypted(&self) -> bool {
82        self.file.is_encrypted()
83    }
84
85    pub fn page_count(&self) -> usize {
86        self.catalog.page_count
87    }
88
89    pub fn page(&self, index: usize) -> Result<PdfPage> {
90        self.catalog.get_page(&self.file, index)
91    }
92
93    pub fn file(&self) -> &PdfFile {
94        &self.file
95    }
96
97    pub fn version(&self) -> (u8, u8) {
98        (self.file.header.major, self.file.header.minor)
99    }
100
101    /// Get decoded content stream bytes for a page.
102    pub fn page_content_bytes(&self, page: &PdfPage) -> Result<Vec<u8>> {
103        let mut all_bytes = Vec::new();
104        for &content_id in &page.contents {
105            match self.file.resolve_stream_data(content_id) {
106                Ok(bytes) => {
107                    if !all_bytes.is_empty() {
108                        all_bytes.push(b'\n');
109                    }
110                    all_bytes.extend_from_slice(&bytes);
111                }
112                Err(e) => {
113                    tracing::warn!("failed to decode content stream {content_id}: {e}");
114                }
115            }
116        }
117        Ok(all_bytes)
118    }
119
120    /// Load all fonts referenced by a page.
121    pub fn load_page_fonts(&self, page: &PdfPage) -> FontCache {
122        font_loader::load_page_fonts(self.file(), page)
123    }
124
125    /// Parse a page's annotations into renderable form (/Rect, /F, the
126    /// /AS-selected appearance stream, /OC membership). Widget annotations for
127    /// interactive-form fields gain a generated appearance when the producer
128    /// left none (or set /NeedAppearances).
129    pub fn page_annotations(&self, page: &PdfPage) -> Vec<Annotation> {
130        annotation::parse_annotations(
131            &self.file,
132            page,
133            &self.catalog,
134            self.named_dests(),
135            self.acro_form(),
136        )
137    }
138
139    /// The document's named-destination map, flattened once and cached for the
140    /// document's lifetime. Backs link-target resolution so the name tree is
141    /// walked at most once, never per page.
142    fn named_dests(&self) -> &HashMap<Vec<u8>, PdfObject> {
143        self.named_dests
144            .get_or_init(|| destinations::collect_named_dests(&self.file))
145    }
146
147    /// The document's interactive form (`/AcroForm`), if any. Parsed once and
148    /// cached for the lifetime of the document.
149    pub fn acro_form(&self) -> Option<&AcroForm> {
150        self.acro_form
151            .get_or_init(|| AcroForm::parse(&self.file))
152            .as_ref()
153    }
154
155    /// The document's default optional-content configuration, if any.
156    pub fn oc_config(&self) -> Option<OcConfig> {
157        optional_content::parse_oc_config(&self.file)
158    }
159
160    /// The document-level output intents (catalog `/OutputIntents`). Empty when
161    /// the document declares none. Page-level intents (PDF 2.0) are carried on
162    /// the page and read via [`PdfDocument::page_output_intents`].
163    pub fn output_intents(&self) -> Vec<OutputIntent> {
164        output_intents::parse_output_intents(&self.file)
165    }
166
167    /// PDF 2.0 page-level `/OutputIntents`, which override the document-level
168    /// intents for that page. Empty for pre-2.0 / most documents.
169    pub fn page_output_intents<'a>(&self, page: &'a PdfPage) -> &'a [OutputIntent] {
170        &page.output_intents
171    }
172
173    /// The document's embedded files — file streams registered in the catalog's
174    /// `/Names /EmbeddedFiles` name tree (a viewer's "attachments"). Empty when
175    /// the document carries none. Pull a file's bytes with
176    /// [`PdfDocument::embedded_file_bytes`].
177    pub fn embedded_files(&self) -> Vec<EmbeddedFile> {
178        embedded_files::parse_embedded_files(&self.file)
179    }
180
181    /// Catalog-level associated files (`/Root /AF`, PDF 2.0). Each carries an
182    /// `/AFRelationship`. Per PDF 2.0 these are also listed by
183    /// [`PdfDocument::embedded_files`]; the two lists usually overlap.
184    pub fn associated_files(&self) -> Vec<EmbeddedFile> {
185        embedded_files::parse_associated_files(&self.file)
186    }
187
188    /// Page-level associated files (`/Page /AF`, PDF 2.0) for one page. `/AF` is
189    /// not inheritable, so only the leaf page dictionary is consulted.
190    pub fn page_associated_files(&self, page: &PdfPage) -> Vec<EmbeddedFile> {
191        match self
192            .file
193            .resolve(page.id)
194            .ok()
195            .and_then(|o| o.as_dict().ok().cloned())
196        {
197            Some(dict) => embedded_files::parse_page_associated_files(&self.file, &dict),
198            None => Vec::new(),
199        }
200    }
201
202    /// Decode and return the bytes of an embedded file. Routes through the
203    /// parser's filter pipeline, so it respects `ParseLimits` (max stream size).
204    /// Errors if the file specification carries no embedded stream
205    /// ([`EmbeddedFile::is_embedded`] is `false`).
206    pub fn embedded_file_bytes(&self, file: &EmbeddedFile) -> Result<Vec<u8>> {
207        match file.stream {
208            Some(id) => self.file.resolve_stream_data(id),
209            // An external file specification has nothing to extract; report the
210            // absent /EF as a missing key rather than a fake object-corruption
211            // error, so a caller can distinguish it from a decode failure.
212            None => Err(Error::MissingKey("EF".into())),
213        }
214    }
215
216    /// The document outline (bookmarks) from the catalog's `/Outlines`, as a
217    /// nested tree of [`OutlineItem`]. Each item's `/Dest` or go-to `/A` is
218    /// resolved to a [`Destination`]; URI / remote-go-to targets are captured as
219    /// strings. Empty when the document has no outline.
220    pub fn outline(&self) -> Vec<OutlineItem> {
221        outline::parse_outlines(&self.file, &self.catalog)
222    }
223
224    /// Resolve a *named* destination (from a named-destination string/name) to a
225    /// [`Destination`]. Tries the `/Names /Dests` name tree and the legacy
226    /// `/Root /Dests` dictionary. `None` when the name is unknown.
227    pub fn named_destination(&self, name: &[u8]) -> Option<Destination> {
228        destinations::resolve_named(&self.file, &self.catalog, name)
229    }
230
231    /// Resolve any destination *value* — an explicit `[page /Fit …]` array, a
232    /// named-destination name/string, a `<< /D … >>` dictionary, or an indirect
233    /// reference to one — to a [`Destination`]. This is what a `/Dest` entry or
234    /// a go-to action's `/D` carries; useful for resolving link-annotation
235    /// targets. `None` when it does not name a destination.
236    pub fn resolve_destination(&self, dest: &PdfObject) -> Option<Destination> {
237        destinations::resolve_explicit(&self.file, &self.catalog, dest)
238    }
239
240    /// The document information dictionary (`/Info`): title, author, subject,
241    /// keywords, creator/producer, and creation/modification dates (raw PDF date
242    /// strings). `None` when the document carries no `/Info` or it is empty.
243    pub fn info(&self) -> Option<DocInfo> {
244        doc_info::parse_info(&self.file)
245    }
246
247    /// The document's page labels (`/PageLabels`, ISO 32000-1 §12.4.2): the
248    /// number tree mapping page indices to the printed labels a viewer shows and
249    /// a user navigates by — e.g. lowercase-roman front matter (`i, ii, …`) then
250    /// decimal body (`1, 2, …`), or a prefixed appendix (`A-1, A-2, …`). These
251    /// are distinct from the physical 0-based page indices. `None` when the
252    /// document declares no page labels. Query a page with [`PageLabels::label`].
253    pub fn page_labels(&self) -> Option<PageLabels> {
254        page_labels::parse_page_labels(&self.file)
255    }
256
257    /// The document's XMP metadata (`/Metadata`, ISO 32000-1 §14.3.2): the common
258    /// Dublin Core / XMP / PDF-schema properties (title, authors, description,
259    /// keywords, producer, creator tool, dates), read with a bounded scrape (no
260    /// XML engine; entity-expansion-safe). `None` when the document carries no
261    /// `/Metadata` or none of the recognized properties. PDF 2.0 prefers this
262    /// over the `/Info` dictionary ([`PdfDocument::info`]).
263    pub fn xmp_metadata(&self) -> Option<XmpMetadata> {
264        xmp::parse_xmp(&self.file)
265    }
266
267    /// The raw bytes of the catalog's `/Metadata` XMP packet (decoded through the
268    /// filter pipeline, respecting `ParseLimits`), for callers that want to parse
269    /// the RDF/XML themselves. `None` when the document carries no `/Metadata`.
270    pub fn metadata_bytes(&self) -> Option<Vec<u8>> {
271        xmp::metadata_bytes(&self.file)
272    }
273}
274
275#[cfg(test)]
276pub(crate) mod test_util {
277    /// Build a synthetic PDF from numbered object bodies (index `i` becomes
278    /// object `i + 1`), with a correct xref table and a trailer whose /Root is
279    /// object 1. Offsets are computed, so bodies can be edited freely.
280    pub fn build_pdf(objects: &[&str]) -> Vec<u8> {
281        let mut buf = Vec::from(&b"%PDF-1.7\n"[..]);
282        let mut offsets = Vec::with_capacity(objects.len());
283        for (i, body) in objects.iter().enumerate() {
284            offsets.push(buf.len());
285            buf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
286        }
287        let xref_off = buf.len();
288        buf.extend_from_slice(
289            format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
290        );
291        for off in &offsets {
292            buf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
293        }
294        buf.extend_from_slice(
295            format!(
296                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF\n",
297                objects.len() + 1
298            )
299            .as_bytes(),
300        );
301        buf
302    }
303}