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