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