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;
9pub mod ink;
10pub mod measure;
11mod obj_util;
12pub mod optional_content;
13pub mod outline;
14pub mod output_intents;
15pub mod page;
16pub mod page_labels;
17pub mod pdfa;
18pub mod pdfua;
19pub mod signature;
20pub mod structure;
21pub mod trust;
22pub mod xmp;
23
24pub use annotation::Annotation;
25pub use catalog::Catalog;
26pub use destinations::{DestView, Destination};
27pub use doc_info::DocInfo;
28pub use embedded_files::{EmbeddedFile, EmbeddedSource};
29pub use forms::{
30    build_resources, escape_text, generate_widget_appearance, standard_font_dict,
31    unicode_to_winansi, AcroForm, FieldKind, FieldValue, FormField, GeneratedAppearance,
32    FF_READONLY,
33};
34pub use ink::{InkAnnotDict, InkAnnotationBuilder};
35pub use measure::{GeographicCoordinateSystem, Measure};
36pub use optional_content::OcConfig;
37pub use outline::OutlineItem;
38pub use output_intents::OutputIntent;
39pub use page::{PdfPage, ResourceDict};
40pub use page_labels::{PageLabelStyle, PageLabels};
41pub use signature::{ByteRangeCoverage, CryptoStatus, DigestStatus, RevocationStatus, Signature};
42pub use structure::{StructElem, StructKid, StructRole, StructTree};
43pub use xmp::XmpMetadata;
44
45use std::collections::HashMap;
46use std::sync::{Arc, OnceLock};
47use zpdf_core::{Error, ParseLimits, PdfObject, Result};
48use zpdf_font::FontCache;
49use zpdf_parser::PdfFile;
50
51pub struct PdfDocument {
52    file: PdfFile,
53    catalog: Catalog,
54    /// Lazily-parsed interactive form, shared across page-annotation calls so
55    /// the field-tree walk runs at most once per document.
56    acro_form: OnceLock<Option<AcroForm>>,
57    /// Lazily-flattened named-destination map, shared across page-annotation
58    /// calls so resolving link targets never re-walks the name tree per page —
59    /// a full-document link scan stays O(pages × links + tree), not O(pages ×
60    /// tree).
61    named_dests: OnceLock<HashMap<Vec<u8>, PdfObject>>,
62}
63
64impl PdfDocument {
65    pub fn open(data: impl Into<Arc<[u8]>>) -> Result<Self> {
66        Self::open_with_limits(data, ParseLimits::default())
67    }
68
69    pub fn open_with_limits(data: impl Into<Arc<[u8]>>, limits: ParseLimits) -> Result<Self> {
70        Self::open_with_password_and_limits(data, b"", limits)
71    }
72
73    /// Open an encrypted document with a user or owner password. Returns
74    /// [`zpdf_core::Error::WrongPassword`] when the password authenticates as
75    /// neither. (A non-encrypted document opens regardless of the password.)
76    pub fn open_with_password(data: impl Into<Arc<[u8]>>, password: &[u8]) -> Result<Self> {
77        Self::open_with_password_and_limits(data, password, ParseLimits::default())
78    }
79
80    pub fn open_with_password_and_limits(
81        data: impl Into<Arc<[u8]>>,
82        password: &[u8],
83        limits: ParseLimits,
84    ) -> Result<Self> {
85        let file = PdfFile::parse_with_password_and_limits(data, password, limits)?;
86        let catalog = Catalog::from_trailer(&file)?;
87        Ok(Self {
88            file,
89            catalog,
90            acro_form: OnceLock::new(),
91            named_dests: OnceLock::new(),
92        })
93    }
94
95    /// True when the document is encrypted (carries an `/Encrypt` dictionary).
96    pub fn is_encrypted(&self) -> bool {
97        self.file.is_encrypted()
98    }
99
100    pub fn page_count(&self) -> usize {
101        self.catalog.page_count
102    }
103
104    pub fn page(&self, index: usize) -> Result<PdfPage> {
105        self.catalog.get_page(&self.file, index)
106    }
107
108    pub fn file(&self) -> &PdfFile {
109        &self.file
110    }
111
112    pub fn version(&self) -> (u8, u8) {
113        (self.file.header.major, self.file.header.minor)
114    }
115
116    /// Get decoded content stream bytes for a page.
117    pub fn page_content_bytes(&self, page: &PdfPage) -> Result<Vec<u8>> {
118        let mut all_bytes = Vec::new();
119        let limit = self.file.limits().max_decoded_stream_bytes;
120        for &content_id in &page.contents {
121            match self.file.resolve_stream_data(content_id) {
122                Ok(bytes) => {
123                    let separator = usize::from(!all_bytes.is_empty());
124                    let Some(combined_len) = (all_bytes.len() as u64)
125                        .checked_add(separator as u64)
126                        .and_then(|n| n.checked_add(bytes.len() as u64))
127                    else {
128                        return Err(Error::StreamSizeLimit(limit));
129                    };
130                    if combined_len > limit {
131                        return Err(Error::StreamSizeLimit(limit));
132                    }
133                    all_bytes
134                        .try_reserve(separator.saturating_add(bytes.len()))
135                        .map_err(|e| {
136                            Error::StreamDecode(format!("page content allocation failed: {e}"))
137                        })?;
138                    if separator != 0 {
139                        all_bytes.push(b'\n');
140                    }
141                    all_bytes.extend_from_slice(&bytes);
142                }
143                Err(e) => {
144                    tracing::warn!("failed to decode content stream {content_id}: {e}");
145                }
146            }
147        }
148        Ok(all_bytes)
149    }
150
151    /// Load all fonts referenced by a page.
152    pub fn load_page_fonts(&self, page: &PdfPage) -> FontCache {
153        font_loader::load_page_fonts(self.file(), page)
154    }
155
156    /// Parse a page's annotations into renderable form (/Rect, /F, the
157    /// /AS-selected appearance stream, /OC membership). Widget annotations for
158    /// interactive-form fields gain a generated appearance when the producer
159    /// left none (or set /NeedAppearances).
160    pub fn page_annotations(&self, page: &PdfPage) -> Vec<Annotation> {
161        annotation::parse_annotations(
162            &self.file,
163            page,
164            &self.catalog,
165            self.named_dests(),
166            self.acro_form(),
167        )
168    }
169
170    /// The document's named-destination map, flattened once and cached for the
171    /// document's lifetime. Backs link-target resolution so the name tree is
172    /// walked at most once, never per page.
173    fn named_dests(&self) -> &HashMap<Vec<u8>, PdfObject> {
174        self.named_dests
175            .get_or_init(|| destinations::collect_named_dests(&self.file))
176    }
177
178    /// The document's interactive form (`/AcroForm`), if any. Parsed once and
179    /// cached for the lifetime of the document.
180    pub fn acro_form(&self) -> Option<&AcroForm> {
181        self.acro_form
182            .get_or_init(|| AcroForm::parse(&self.file))
183            .as_ref()
184    }
185
186    /// The document's default optional-content configuration, if any.
187    pub fn oc_config(&self) -> Option<OcConfig> {
188        optional_content::parse_oc_config(&self.file)
189    }
190
191    /// The document-level output intents (catalog `/OutputIntents`). Empty when
192    /// the document declares none. Page-level intents (PDF 2.0) are carried on
193    /// the page and read via [`PdfDocument::page_output_intents`].
194    pub fn output_intents(&self) -> Vec<OutputIntent> {
195        output_intents::parse_output_intents(&self.file)
196    }
197
198    /// PDF 2.0 page-level `/OutputIntents`, which override the document-level
199    /// intents for that page. Empty for pre-2.0 / most documents.
200    pub fn page_output_intents<'a>(&self, page: &'a PdfPage) -> &'a [OutputIntent] {
201        &page.output_intents
202    }
203
204    /// The document's embedded files — file streams registered in the catalog's
205    /// `/Names /EmbeddedFiles` name tree (a viewer's "attachments"). Empty when
206    /// the document carries none. Pull a file's bytes with
207    /// [`PdfDocument::embedded_file_bytes`].
208    pub fn embedded_files(&self) -> Vec<EmbeddedFile> {
209        embedded_files::parse_embedded_files(&self.file)
210    }
211
212    /// Catalog-level associated files (`/Root /AF`, PDF 2.0). Each carries an
213    /// `/AFRelationship`. Per PDF 2.0 these are also listed by
214    /// [`PdfDocument::embedded_files`]; the two lists usually overlap.
215    pub fn associated_files(&self) -> Vec<EmbeddedFile> {
216        embedded_files::parse_associated_files(&self.file)
217    }
218
219    /// Page-level associated files (`/Page /AF`, PDF 2.0) for one page. `/AF` is
220    /// not inheritable, so only the leaf page dictionary is consulted.
221    pub fn page_associated_files(&self, page: &PdfPage) -> Vec<EmbeddedFile> {
222        match self
223            .file
224            .resolve(page.id)
225            .ok()
226            .and_then(|o| o.as_dict().ok().cloned())
227        {
228            Some(dict) => embedded_files::parse_page_associated_files(&self.file, &dict),
229            None => Vec::new(),
230        }
231    }
232
233    /// Decode and return the bytes of an embedded file. Routes through the
234    /// parser's filter pipeline, so it respects `ParseLimits` (max stream size).
235    /// Errors if the file specification carries no embedded stream
236    /// ([`EmbeddedFile::is_embedded`] is `false`).
237    pub fn embedded_file_bytes(&self, file: &EmbeddedFile) -> Result<Vec<u8>> {
238        match file.stream {
239            Some(id) => self.file.resolve_stream_data(id),
240            // An external file specification has nothing to extract; report the
241            // absent /EF as a missing key rather than a fake object-corruption
242            // error, so a caller can distinguish it from a decode failure.
243            None => Err(Error::MissingKey("EF".into())),
244        }
245    }
246
247    /// The document outline (bookmarks) from the catalog's `/Outlines`, as a
248    /// nested tree of [`OutlineItem`]. Each item's `/Dest` or go-to `/A` is
249    /// resolved to a [`Destination`]; URI / remote-go-to targets are captured as
250    /// strings. Empty when the document has no outline.
251    pub fn outline(&self) -> Vec<OutlineItem> {
252        outline::parse_outlines(&self.file, &self.catalog)
253    }
254
255    /// Resolve a *named* destination (from a named-destination string/name) to a
256    /// [`Destination`]. Tries the `/Names /Dests` name tree and the legacy
257    /// `/Root /Dests` dictionary. `None` when the name is unknown.
258    pub fn named_destination(&self, name: &[u8]) -> Option<Destination> {
259        destinations::resolve_named(&self.file, &self.catalog, name)
260    }
261
262    /// Resolve any destination *value* — an explicit `[page /Fit …]` array, a
263    /// named-destination name/string, a `<< /D … >>` dictionary, or an indirect
264    /// reference to one — to a [`Destination`]. This is what a `/Dest` entry or
265    /// a go-to action's `/D` carries; useful for resolving link-annotation
266    /// targets. `None` when it does not name a destination.
267    pub fn resolve_destination(&self, dest: &PdfObject) -> Option<Destination> {
268        destinations::resolve_explicit(&self.file, &self.catalog, dest)
269    }
270
271    /// The document information dictionary (`/Info`): title, author, subject,
272    /// keywords, creator/producer, and creation/modification dates (raw PDF date
273    /// strings). `None` when the document carries no `/Info` or it is empty.
274    pub fn info(&self) -> Option<DocInfo> {
275        doc_info::parse_info(&self.file)
276    }
277
278    /// The document's page labels (`/PageLabels`, ISO 32000-1 §12.4.2): the
279    /// number tree mapping page indices to the printed labels a viewer shows and
280    /// a user navigates by — e.g. lowercase-roman front matter (`i, ii, …`) then
281    /// decimal body (`1, 2, …`), or a prefixed appendix (`A-1, A-2, …`). These
282    /// are distinct from the physical 0-based page indices. `None` when the
283    /// document declares no page labels. Query a page with [`PageLabels::label`].
284    pub fn page_labels(&self) -> Option<PageLabels> {
285        page_labels::parse_page_labels(&self.file)
286    }
287
288    /// The document's XMP metadata (`/Metadata`, ISO 32000-1 §14.3.2): the common
289    /// Dublin Core / XMP / PDF-schema properties (title, authors, description,
290    /// keywords, producer, creator tool, dates), read with a bounded scrape (no
291    /// XML engine; entity-expansion-safe). `None` when the document carries no
292    /// `/Metadata` or none of the recognized properties. PDF 2.0 prefers this
293    /// over the `/Info` dictionary ([`PdfDocument::info`]).
294    pub fn xmp_metadata(&self) -> Option<XmpMetadata> {
295        xmp::parse_xmp(&self.file)
296    }
297
298    /// The raw bytes of the catalog's `/Metadata` XMP packet (decoded through the
299    /// filter pipeline, respecting `ParseLimits`), for callers that want to parse
300    /// the RDF/XML themselves. `None` when the document carries no `/Metadata`.
301    pub fn metadata_bytes(&self) -> Option<Vec<u8>> {
302        xmp::metadata_bytes(&self.file)
303    }
304
305    /// The document's logical structure tree (`/StructTreeRoot`, ISO 32000-1
306    /// §14.7–14.8): the Tagged-PDF tree of structure elements (headings,
307    /// paragraphs, lists, tables, figures …) with their roles, accessibility
308    /// text, and marked-content / object associations. `None` when the document
309    /// declares no structure tree. Read-only; runs only when called.
310    pub fn struct_tree(&self) -> Option<StructTree> {
311        structure::parse_struct_tree(&self.file, &self.catalog)
312    }
313
314    /// Whether the document declares Tagged-PDF conformance via the catalog's
315    /// `/MarkInfo` dictionary (`/Marked true`). Independent of whether a
316    /// [`PdfDocument::struct_tree`] is actually present.
317    pub fn is_tagged(&self) -> bool {
318        structure::is_tagged(&self.file)
319    }
320
321    /// The document's digital signatures (`/Sig` form fields, ISO 32000-1
322    /// §12.8). Each [`Signature`] carries the signature dictionary's metadata,
323    /// its `/ByteRange` coverage, a byte-range **integrity** verdict
324    /// ([`DigestStatus`]) obtained by recomputing the covered-bytes digest and
325    /// comparing it to the digest embedded in the CMS blob, and a
326    /// **cryptographic** verdict ([`CryptoStatus`]) from verifying the signer's
327    /// RSA/ECDSA signature over the signed attributes against the embedded
328    /// certificate's public key. This does *not* validate certificate trust,
329    /// revocation, or signing-time validity — see the [`signature`] module docs
330    /// and [`Signature::is_cryptographically_valid`]. Empty when the document
331    /// carries no signatures. Read-only; runs only when called.
332    pub fn signatures(&self) -> Vec<Signature> {
333        signature::parse_signatures(&self.file)
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn combined_page_content_respects_decode_limit() {
343        let pdf = test_util::build_pdf(&[
344            "<< /Type /Catalog /Pages 2 0 R >>",
345            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
346            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Contents [4 0 R 5 0 R] >>",
347            "<< /Length 3 >>\nstream\nABC\nendstream",
348            "<< /Length 3 >>\nstream\nDEF\nendstream",
349        ]);
350        let limits = ParseLimits {
351            max_decoded_stream_bytes: 6,
352            ..ParseLimits::default()
353        };
354        let doc = PdfDocument::open_with_limits(pdf, limits).unwrap();
355        let page = doc.page(0).unwrap();
356        assert!(matches!(
357            doc.page_content_bytes(&page),
358            Err(Error::StreamSizeLimit(6))
359        ));
360    }
361}
362
363#[cfg(test)]
364pub(crate) mod test_util {
365    /// Build a synthetic PDF from numbered object bodies (index `i` becomes
366    /// object `i + 1`), with a correct xref table and a trailer whose /Root is
367    /// object 1. Offsets are computed, so bodies can be edited freely.
368    pub fn build_pdf(objects: &[&str]) -> Vec<u8> {
369        let mut buf = Vec::from(&b"%PDF-1.7\n"[..]);
370        let mut offsets = Vec::with_capacity(objects.len());
371        for (i, body) in objects.iter().enumerate() {
372            offsets.push(buf.len());
373            buf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
374        }
375        let xref_off = buf.len();
376        buf.extend_from_slice(
377            format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
378        );
379        for off in &offsets {
380            buf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
381        }
382        buf.extend_from_slice(
383            format!(
384                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF\n",
385                objects.len() + 1
386            )
387            .as_bytes(),
388        );
389        buf
390    }
391}