Skip to main content

stet_pdf_reader/
lib.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! PDF parser, page navigator, and content stream interpreter.
6//!
7//! `stet-pdf-reader` is a self-contained PDF reader: it opens a PDF, walks
8//! its object graph, and interprets each page's content stream into a
9//! `stet_graphics::display_list::DisplayList` that any downstream consumer
10//! (rasterizer, PDF writer, custom output device) can render.
11//!
12//! The crate intentionally has **no dependency on `stet-core`** — it uses
13//! only `stet-fonts` (font parsing) and `stet-graphics` (display list and
14//! ICC types), so it can be used as a standalone PDF parser/renderer
15//! without pulling in the PostScript interpreter.
16//!
17//! # Quick start
18//!
19//! ```no_run
20//! use stet_pdf_reader::PdfDocument;
21//!
22//! let data = std::fs::read("document.pdf")?;
23//! let doc = PdfDocument::from_bytes(&data)?;
24//!
25//! for page in 0..doc.page_count() {
26//!     let display_list = doc.render_page(page, 150.0)?;
27//!     // …consume the display list (rasterize, convert, inspect, etc.)
28//! }
29//! # Ok::<(), Box<dyn std::error::Error>>(())
30//! ```
31//!
32//! With the default `render` feature enabled, [`PdfDocument::render_page_to_rgba`]
33//! skips the display-list-handling boilerplate and produces RGBA pixels
34//! directly via `stet-render`.
35//!
36//! # Encrypted PDFs
37//!
38//! `from_bytes` / `from_bytes_with_icc` try the empty password. If the
39//! file uses a non-empty user password they return
40//! [`PdfError::PasswordRequired`]; the caller can then prompt the user
41//! and retry with [`PdfDocument::from_bytes_with_password`]:
42//!
43//! ```no_run
44//! use stet_pdf_reader::{PdfDocument, PdfError};
45//! use stet_graphics::icc::IccCache;
46//!
47//! let data = std::fs::read("encrypted.pdf")?;
48//! let doc = match PdfDocument::from_bytes(&data) {
49//!     Ok(doc) => doc,
50//!     Err(PdfError::PasswordRequired) => {
51//!         let pw = prompt_user_for_password();
52//!         PdfDocument::from_bytes_with_password(&data, IccCache::new(), pw.as_bytes())?
53//!     }
54//!     Err(e) => return Err(e.into()),
55//! };
56//! # fn prompt_user_for_password() -> String { String::new() }
57//! # Ok::<(), Box<dyn std::error::Error>>(())
58//! ```
59//!
60//! RC4 (40/128-bit), AES-128, and AES-256 (R=5/6) are all supported.
61//!
62//! # Structural API
63//!
64//! In addition to rendering, [`PdfDocument`] exposes typed, read-only
65//! access to a document's structural content — for indexers,
66//! accessibility tools, link extractors, format converters, and other
67//! consumers that want to *inspect* a PDF rather than display it.
68//!
69//! Every accessor parses lazily on first call and caches its result;
70//! a document the caller only renders pays nothing for the structural
71//! API surface.
72//!
73//! ```no_run
74//! use stet_pdf_reader::PdfDocument;
75//!
76//! let data = std::fs::read("document.pdf")?;
77//! let doc = PdfDocument::from_bytes(&data)?;
78//!
79//! // Document metadata (Info dict + XMP).
80//! let m = doc.metadata();
81//! println!("Title:    {:?}", m.title);
82//! println!("Author:   {:?}", m.author);
83//! println!("Producer: {:?}", m.producer);
84//!
85//! // Outline / bookmarks.
86//! for item in doc.outline() {
87//!     println!("- {} ({} children)", item.title, item.children.len());
88//! }
89//!
90//! // Annotations on page 1.
91//! for annot in doc.page_annotations(0)? {
92//!     println!("{:?} at {:?}", annot.kind, annot.rect);
93//! }
94//!
95//! // AcroForm field tree.
96//! if let Some(form) = doc.form() {
97//!     for field in &form.fields {
98//!         println!("{}: {:?}", field.name, field.value);
99//!     }
100//! }
101//!
102//! // Embedded file attachments.
103//! for (name, file) in doc.embedded_files() {
104//!     let bytes = doc.embedded_file_bytes(name)?;
105//!     println!("{name} ({} bytes, {:?})", bytes.len(), file.mime_type);
106//! }
107//!
108//! // Optional Content (layers).
109//! for layer in doc.layers() {
110//!     println!("layer {} {:?} default_visible={}",
111//!         layer.ocg_id, layer.name, layer.default_visible);
112//! }
113//!
114//! // Recoverable parse problems (cycles, dropped entries, etc.).
115//! for w in doc.parse_warnings().iter() {
116//!     eprintln!("[{:?}] {:?}: {}", w.severity, w.phase, w.message);
117//! }
118//! # Ok::<(), Box<dyn std::error::Error>>(())
119//! ```
120//!
121//! Full accessor list, each cached after first call:
122//!
123//! - [`metadata`](PdfDocument::metadata) — Info dict + XMP
124//! - [`viewer_preferences`](PdfDocument::viewer_preferences) — display hints
125//! - [`outline`](PdfDocument::outline) — bookmark tree
126//! - [`destinations`](PdfDocument::destinations) +
127//!   [`resolve_named_destination`](PdfDocument::resolve_named_destination) —
128//!   named-destination table
129//! - [`page_annotations`](PdfDocument::page_annotations) — per-page typed annotations
130//! - [`form`](PdfDocument::form) — AcroForm field tree
131//! - [`page_boxes`](PdfDocument::page_boxes) — all 5 page boxes + presentation hints
132//! - [`embedded_files`](PdfDocument::embedded_files) +
133//!   [`embedded_file_bytes`](PdfDocument::embedded_file_bytes) — file attachments
134//! - [`layers`](PdfDocument::layers) + [`layer`](PdfDocument::layer) —
135//!   Optional Content Group (layer) metadata
136//! - [`configurations`](PdfDocument::configurations) +
137//!   [`default_configuration`](PdfDocument::default_configuration) +
138//!   [`layer_tree`](PdfDocument::layer_tree) — layer hierarchy and
139//!   alternate configurations
140//! - [`layer_set_for`](PdfDocument::layer_set_for) — intent-driven
141//!   `LayerSet` that applies the document's `/AS` automatic-state
142//!   rules; pair with
143//!   [`render_page_to_rgba_with_layers`](PdfDocument::render_page_to_rgba_with_layers)
144//!   for view / print / export rendering
145//! - [`parse_warnings`](PdfDocument::parse_warnings) — diagnostics
146//!
147//! Walkers that recurse over potentially-cyclic structures
148//! (outline tree, name trees, form-field tree) bound traversal with a
149//! visited-set and a depth cap; truncations are surfaced via
150//! [`parse_warnings`](PdfDocument::parse_warnings) so a missing branch
151//! is never silent.
152//!
153//! For a longer-form reference with one focused example per accessor,
154//! see the [PDF Reader API
155//! guide](https://github.com/AndyCappDev/stet/blob/main/docs/PDF-READER-API.md)
156//! in the repository. The Optional Content / layer surface
157//! ([`Layer`], [`Configuration`], [`LayerSet`], [`OcgVisibility`],
158//! [`RenderIntent`]) has its own reference at
159//! [`docs/PDF-LAYERS.md`](https://github.com/AndyCappDev/stet/blob/main/docs/PDF-LAYERS.md).
160//!
161//! # Acknowledgements
162//!
163//! JPEG 2000, JBIG2, and CCITT-Fax stream decoding use the
164//! [`hayro-jpeg2000`](https://crates.io/crates/hayro-jpeg2000),
165//! [`hayro-jbig2`](https://crates.io/crates/hayro-jbig2), and
166//! [`hayro-ccitt`](https://crates.io/crates/hayro-ccitt) crates from the
167//! [hayro](https://github.com/LaurenzV/hayro) PDF renderer by Laurenz
168//! Stampfl. Big thanks to the hayro project for factoring those decoders
169//! out as reusable crates — `stet-pdf-reader` would not cover the full
170//! PDF stream-filter surface without them.
171
172pub mod annotations;
173pub mod content;
174pub mod crypto;
175pub mod destination;
176pub mod diagnostics;
177pub mod embedded_files;
178pub mod error;
179pub mod filters;
180pub mod form_fields;
181pub mod layers;
182pub mod lexer;
183pub mod metadata;
184pub mod name_tree;
185pub mod objects;
186pub mod outline;
187pub mod page_boxes;
188pub mod page_tree;
189pub mod resolver;
190pub mod resources;
191pub mod viewer_prefs;
192pub mod xref;
193
194pub use annotations::{
195    Annotation, AnnotationColor, AnnotationDate, AnnotationFlags, AnnotationKind,
196    AnnotationKindData, Border, CaretAnnotation, FileAttachmentAnnotation, FreeTextAnnotation,
197    InkAnnotation, LineAnnotation, LinkAnnotation, MarkupAnnotation, PolygonAnnotation,
198    PopupAnnotation, ShapeAnnotation, StampAnnotation, TextAnnotation,
199};
200pub use destination::{Action, Destination, ViewSpec};
201pub use diagnostics::{LocationHint, ParsePhase, ParseWarning, Severity, WarningSink};
202pub use embedded_files::{AfRelationship, EmbeddedFile};
203pub use error::PdfError;
204pub use form_fields::{
205    ButtonField, ButtonType, ChoiceField, ChoiceOption, FieldFlags, FieldKind, FieldValue,
206    FormCatalog, FormField, SigFlags, SignatureField, TextField,
207};
208pub use layers::{
209    AutoStateEvent, AutoStateRule, BaseState, Configuration, CreatorInfo, ExportUsage,
210    LanguageUsage, Layer, LayerIntent, LayerSet, LayerTree, LayerTreeNode, LayerUsage, ListMode,
211    MembershipPolicy, OcgVisibility, PageElementSubtype, PrintUsage, RenderIntent, UsageState,
212    UserUsage, ViewUsage, VisibilityExpr, ZoomUsage,
213};
214pub use metadata::{DocumentMetadata, PdfDate, TrappedFlag};
215pub use objects::{PdfDict, PdfObj};
216pub use outline::{OutlineItem, OutlineStyle};
217pub use page_boxes::PageBoxes;
218pub use page_tree::PageInfo;
219pub use viewer_prefs::{
220    Duplex, PageLayout, PageMode, PrintScaling, ReadingDirection, ViewerPreferences,
221};
222
223use content::ContentInterpreter;
224use resolver::Resolver;
225use std::cell::OnceCell;
226use std::collections::{HashMap, HashSet};
227use std::sync::Arc;
228use stet_fonts::geometry::Matrix;
229use stet_graphics::display_list::DisplayList;
230use stet_graphics::document_structure::OutputIntentRecord;
231use stet_graphics::icc::IccCache;
232
233/// Font data provider: maps a font file name (e.g. "NimbusSans-Regular") to raw .t1 bytes.
234///
235/// Used for environments without filesystem access (WASM) where fonts are embedded.
236pub type FontProvider = Arc<dyn Fn(&str) -> Option<Vec<u8>> + Send + Sync>;
237
238/// A parsed PDF document.
239pub struct PdfDocument<'a> {
240    resolver: Resolver<'a>,
241    pages: Vec<PageInfo>,
242    icc_cache: IccCache,
243    font_provider: Option<FontProvider>,
244    /// When false (default), PDF overprint flags (OP/op) are suppressed —
245    /// skips the expensive CMYK buffer simulation that most viewers omit.
246    overprint: bool,
247    /// Object numbers of Optional Content Groups that are OFF by default.
248    /// Parsed from the catalog's /OCProperties /D /OFF array.
249    ocg_off: HashSet<u32>,
250    /// Decompressed ICC profile bytes from the first /OutputIntents entry's
251    /// /DestOutputProfile stream, if present. Used to match the document's
252    /// intended CMYK rendering (ISO Coated v2, SWOP, etc.) at render time.
253    output_intent_icc: Option<Vec<u8>>,
254    /// Document metadata (Info dict + XMP), parsed lazily on first access.
255    metadata_cache: OnceCell<DocumentMetadata>,
256    /// Viewer preferences, parsed lazily on first access.
257    viewer_prefs_cache: OnceCell<ViewerPreferences>,
258    /// Outline tree, parsed lazily on first access.
259    outline_cache: OnceCell<Vec<OutlineItem>>,
260    /// Named destinations (legacy /Dests + /Names /Dests name tree),
261    /// parsed lazily on first access.
262    destinations_cache: OnceCell<HashMap<String, Destination>>,
263    /// Per-page annotation lists. Each `OnceCell` parses on first
264    /// access for that page only — large documents don't pay for
265    /// pages a caller never visits.
266    page_annotations_cache: Vec<OnceCell<Vec<Annotation>>>,
267    /// AcroForm catalog, parsed lazily on first access. Outer
268    /// `OnceCell` caches the parse; inner `Option` reflects
269    /// presence/absence of `/AcroForm`.
270    form_cache: OnceCell<Option<FormCatalog>>,
271    /// Embedded files (file attachments), parsed lazily on first
272    /// access from the catalog's `/Names /EmbeddedFiles` name tree.
273    embedded_files_cache: OnceCell<HashMap<String, EmbeddedFile>>,
274    /// Optional Content Groups (layers), parsed lazily on first
275    /// access from the catalog's `/OCProperties /OCGs`.
276    layers_cache: OnceCell<Vec<Layer>>,
277    /// Layer configurations (default `/D` plus alternates from
278    /// `/Configs`), parsed lazily on first access.
279    configurations_cache: OnceCell<Vec<Configuration>>,
280    /// Parse-time warnings accumulated by structural parsers
281    /// (outline, annotations, form fields, ...). The sink uses
282    /// interior mutability so accessors can record warnings while
283    /// holding only `&self`.
284    warnings: WarningSink,
285}
286
287impl<'a> PdfDocument<'a> {
288    /// Parse a PDF from bytes.
289    pub fn from_bytes(data: &'a [u8]) -> Result<Self, PdfError> {
290        let mut icc_cache = IccCache::new();
291        icc_cache.search_system_cmyk_profile();
292        Self::from_bytes_inner(data, icc_cache, b"")
293    }
294
295    /// Parse a PDF from bytes, using a pre-loaded ICC cache.
296    ///
297    /// Use this when the caller already has an `IccCache` with the system
298    /// CMYK profile loaded (e.g., from the PostScript interpreter context).
299    pub fn from_bytes_with_icc(data: &'a [u8], icc_cache: IccCache) -> Result<Self, PdfError> {
300        Self::from_bytes_inner(data, icc_cache, b"")
301    }
302
303    /// Parse a PDF from bytes using a user-supplied password.
304    ///
305    /// Returns `PdfError::PasswordRequired` if the password does not
306    /// match; callers can retry by calling this again with a different
307    /// password.
308    pub fn from_bytes_with_password(
309        data: &'a [u8],
310        icc_cache: IccCache,
311        password: &[u8],
312    ) -> Result<Self, PdfError> {
313        Self::from_bytes_inner(data, icc_cache, password)
314    }
315
316    fn from_bytes_inner(
317        data: &'a [u8],
318        icc_cache: IccCache,
319        password: &[u8],
320    ) -> Result<Self, PdfError> {
321        // Validate header — PDF spec allows up to 1024 bytes before %PDF-
322        if !has_pdf_header(data) {
323            return Err(PdfError::NotAPdf);
324        }
325
326        let xref = xref::parse_xref(data)?;
327
328        // Handle encryption. /Encrypt null means no encryption (some
329        // generators emit this).
330        let encryption = if let Some(encrypt_ref) = xref.trailer.get(b"Encrypt") {
331            if matches!(encrypt_ref, crate::objects::PdfObj::Null) {
332                None
333            } else {
334                // Temporary resolver (without encryption) to dereference
335                // the Encrypt dict itself.
336                let temp_resolver = Resolver::new(data, &xref);
337                let encrypt_obj = temp_resolver.deref(encrypt_ref)?;
338                let encrypt_dict = encrypt_obj
339                    .as_dict()
340                    .ok_or(PdfError::Other("Encrypt is not a dict".into()))?;
341
342                let file_id = xref
343                    .trailer
344                    .get_array(b"ID")
345                    .and_then(|arr| arr.first()?.as_str().map(|s| s.to_vec()))
346                    .unwrap_or_default();
347
348                Some(crypto::EncryptionState::try_open_with_password(
349                    encrypt_dict,
350                    &xref.trailer,
351                    &file_id,
352                    password,
353                )?)
354            }
355        } else {
356            None
357        };
358
359        let resolver = Resolver::with_encryption(data, xref, encryption);
360        let pages = page_tree::collect_pages(&resolver)?;
361        let ocg_off = parse_ocg_off(&resolver);
362        let output_intent_icc = parse_output_intent_icc(&resolver);
363
364        let page_annotations_cache = (0..pages.len()).map(|_| OnceCell::new()).collect();
365        Ok(Self {
366            resolver,
367            pages,
368            icc_cache,
369            font_provider: None,
370            overprint: true,
371            ocg_off,
372            output_intent_icc,
373            metadata_cache: OnceCell::new(),
374            viewer_prefs_cache: OnceCell::new(),
375            outline_cache: OnceCell::new(),
376            destinations_cache: OnceCell::new(),
377            page_annotations_cache,
378            form_cache: OnceCell::new(),
379            embedded_files_cache: OnceCell::new(),
380            layers_cache: OnceCell::new(),
381            configurations_cache: OnceCell::new(),
382            warnings: WarningSink::new(),
383        })
384    }
385
386    /// Enable or disable PDF overprint simulation.
387    ///
388    /// Enabled by default. When disabled, OP/op flags in graphics state dicts
389    /// are ignored, avoiding CMYK buffer tracking.
390    pub fn set_overprint(&mut self, enabled: bool) {
391        self.overprint = enabled;
392    }
393
394    /// Set a font data provider for environments without filesystem access.
395    pub fn set_font_provider(&mut self, provider: FontProvider) {
396        self.font_provider = Some(provider);
397    }
398
399    /// Number of pages in the document.
400    pub fn page_count(&self) -> usize {
401        self.pages.len()
402    }
403
404    /// Page dimensions in points (width, height), accounting for rotation.
405    pub fn page_size(&self, page: usize) -> Result<(f64, f64), PdfError> {
406        let info = self
407            .pages
408            .get(page)
409            .ok_or(PdfError::PageOutOfRange(page, self.pages.len()))?;
410        let [llx, lly, urx, ury] = info.crop_box;
411        let (w, h) = ((urx - llx).abs(), (ury - lly).abs());
412        match info.rotate.rem_euclid(360) {
413            90 | 270 => Ok((h, w)),
414            _ => Ok((w, h)),
415        }
416    }
417
418    /// Get page info (MediaBox, CropBox, rotation, resources).
419    pub fn page_info(&self, page: usize) -> Result<&PageInfo, PdfError> {
420        self.pages
421            .get(page)
422            .ok_or(PdfError::PageOutOfRange(page, self.pages.len()))
423    }
424
425    /// Get the decompressed content stream bytes for a page.
426    /// If the page has multiple content streams, they are concatenated
427    /// with a newline separator.
428    pub fn page_contents(&self, page: usize) -> Result<Vec<u8>, PdfError> {
429        let info = self
430            .pages
431            .get(page)
432            .ok_or(PdfError::PageOutOfRange(page, self.pages.len()))?;
433
434        if info.contents.is_empty() {
435            return Ok(Vec::new());
436        }
437
438        let mut result = Vec::new();
439        for (i, &(obj_num, gen_num)) in info.contents.iter().enumerate() {
440            // Skip content stream refs that fail (e.g., dict without stream body
441            // in malformed PDFs). Continue with remaining streams.
442            match self.resolver.stream_data(obj_num, gen_num) {
443                Ok(data) => {
444                    if i > 0 && !result.is_empty() {
445                        result.push(b'\n');
446                    }
447                    result.extend_from_slice(&data);
448                }
449                Err(_) => continue,
450            }
451        }
452
453        Ok(result)
454    }
455
456    /// Render a page to a DisplayList at the given DPI.
457    ///
458    /// The display list uses device-space coordinates (paths pre-transformed
459    /// through the initial CTM). The initial CTM applies DPI scaling, Y-flip,
460    /// and CropBox offset.
461    pub fn render_page(&self, page: usize, dpi: f64) -> Result<DisplayList, PdfError> {
462        let info = self
463            .pages
464            .get(page)
465            .ok_or(PdfError::PageOutOfRange(page, self.pages.len()))?;
466
467        let [llx, lly, urx, ury] = info.crop_box;
468        let (page_w, page_h) = ((urx - llx).abs(), (ury - lly).abs());
469
470        // Build initial CTM: scale by dpi/72, Y-flip (PDF Y-up → device Y-down),
471        // and offset by CropBox origin.
472        let scale = dpi / 72.0;
473        let ctm = match info.rotate.rem_euclid(360) {
474            90 => {
475                // Rotate 90° CW + Y-flip: (x,y) → (y*s, x*s)
476                Matrix::new(0.0, scale, scale, 0.0, 0.0, 0.0).concat(&Matrix::translate(-llx, -lly))
477            }
478            180 => {
479                // Rotate 180° + Y-flip = just X-flip
480                Matrix::new(-scale, 0.0, 0.0, scale, page_w * scale, 0.0)
481                    .concat(&Matrix::translate(-llx, -lly))
482            }
483            270 => {
484                // Rotate 270° CW + Y-flip: (x,y) → ((page_h-y)*s, (page_w-x)*s)
485                Matrix::new(0.0, -scale, -scale, 0.0, page_h * scale, page_w * scale)
486                    .concat(&Matrix::translate(-llx, -lly))
487            }
488            _ => {
489                // No rotation: scale + Y-flip + CropBox offset
490                // PDF (0,0) at bottom-left → device (0, page_h*scale) at top-left
491                Matrix::new(scale, 0.0, 0.0, -scale, -llx * scale, ury * scale)
492            }
493        };
494
495        // Get page content stream
496        let content_data = self.page_contents(page)?;
497
498        // Interpret content stream
499        let mut interpreter = ContentInterpreter::new(
500            &self.resolver,
501            info.resources.clone(),
502            ctm,
503            &self.icc_cache,
504            self.font_provider.clone(),
505            self.overprint,
506            &self.ocg_off,
507        );
508
509        // Check if the page has a DeviceCMYK transparency group — if so,
510        // RGB colors need round-tripping through CMYK to match compositing
511        // in CMYK space (mutes saturated out-of-gamut RGB colors).
512        //
513        // PDF/X-4 files often omit an explicit page /Group but declare a
514        // CMYK destination via /OutputIntents. Treat those as having an
515        // implicit DeviceCMYK group so DeviceGray content (e.g. JBIG2
516        // images marked /ColorSpace /DeviceGray) is K-only-promoted to
517        // match DeviceCMYK [0,0,0,K] fills painted alongside it
518        // (GWG 17.3 JBIG2 compression test).
519        let explicit_cmyk_group = if let Ok(page_obj) = self.resolver.resolve(info.obj_num, 0)
520            && let Some(page_dict) = page_obj.as_dict()
521            && let Some(group_obj) = page_dict.get(b"Group")
522            && let Ok(group_resolved) = self.resolver.deref(group_obj)
523            && let Some(group_dict) = group_resolved.as_dict()
524            && group_dict.get_name(b"CS") == Some(b"DeviceCMYK")
525        {
526            true
527        } else {
528            false
529        };
530        let page_group_is_cmyk = explicit_cmyk_group || self.output_intent_icc.is_some();
531        if page_group_is_cmyk {
532            interpreter.set_page_group_cmyk();
533        }
534        // Stricter PDF/X compositing rules (DeviceGray-to-K promotion) only
535        // apply when the document declares an output intent — those documents
536        // opt into the output profile's paper white. Plain `/Group /CS
537        // /DeviceCMYK` without an output intent (e.g. 3000_5.pdf, 2495.pdf)
538        // is just a DeviceCMYK transparency group and must keep DeviceGray
539        // rendering at exact RGB(g, g, g) so a `0.5 g` paint stays the
540        // expected mid-gray instead of picking up the system profile's paper
541        // white. The complementary `in_smask_form` guard inside the
542        // interpreter handles the SMask-source exception that PDF/X documents
543        // need (parse-time suppression mirroring `suspend_default_cmyk`).
544        if self.output_intent_icc.is_some() {
545            interpreter.set_pdfx_cmyk_intent();
546        }
547
548        // Render page content
549        if let Err(e) = interpreter.interpret_stream_public(&content_data) {
550            eprintln!("warning: content stream error: {}", e);
551        }
552        // Unwind any unbalanced q's left by the content stream.
553        interpreter.unwind_gstate_stack();
554
555        // Render annotation appearance streams (form field values, stamps, etc.)
556        if !info.annots.is_empty() {
557            interpreter.reset_clip_for_annotations();
558            for &(n, g) in &info.annots {
559                let _ = interpreter.render_annotation(n, g);
560            }
561        }
562
563        let mut dl = interpreter.into_display_list();
564        if page_group_is_cmyk {
565            dl.set_page_group_color_space(stet_graphics::display_list::GroupColorSpace::DeviceCMYK);
566        }
567        Ok(dl)
568    }
569
570    /// Render a page to RGBA pixel data at the given DPI.
571    ///
572    /// Returns (pixel_data, width, height). Pixel data is RGBA, 4 bytes per pixel.
573    #[cfg(feature = "render")]
574    pub fn render_page_to_rgba(
575        &self,
576        page: usize,
577        dpi: f64,
578    ) -> Result<(Vec<u8>, u32, u32), PdfError> {
579        self.render_page_to_rgba_with_layers(page, dpi, &LayerSet::new())
580    }
581
582    /// Like [`render_page_to_rgba`](Self::render_page_to_rgba) but
583    /// consults the supplied [`LayerSet`] when evaluating each
584    /// `OcgGroup`'s visibility.
585    ///
586    /// Pass an empty `LayerSet::new()` (or use the plain
587    /// `render_page_to_rgba`) to fall back to each layer's
588    /// `default_visible` baked from the document's default
589    /// configuration. Use [`layers::layer_set_from_document`] or
590    /// [`layers::layer_set_from_configuration`] to build a populated
591    /// set, then mutate it with `set` / `clear` before passing it
592    /// here.
593    #[cfg(feature = "render")]
594    pub fn render_page_to_rgba_with_layers(
595        &self,
596        page: usize,
597        dpi: f64,
598        layer_set: &LayerSet,
599    ) -> Result<(Vec<u8>, u32, u32), PdfError> {
600        let (page_w, page_h) = self.page_size(page)?;
601        let scale = dpi / 72.0;
602        let pixel_w = (page_w * scale).round() as u32;
603        let pixel_h = (page_h * scale).round() as u32;
604
605        let display_list = self.render_page(page, dpi)?;
606
607        let rgba = stet_render::render_to_rgba_with_layers(
608            &display_list,
609            pixel_w,
610            pixel_h,
611            dpi,
612            Some(&self.icc_cache),
613            false,
614            layer_set,
615        );
616
617        Ok((rgba, pixel_w, pixel_h))
618    }
619
620    /// Access the ICC color profile cache.
621    pub fn icc_cache(&self) -> &IccCache {
622        &self.icc_cache
623    }
624
625    /// Decompressed ICC profile bytes from the PDF's OutputIntent, if any.
626    /// PDF/X files declare their intended CMYK rendering space here (e.g.
627    /// ISO Coated v2 300% (ECI)); using it at render time matches the
628    /// document author's colour expectations, which system-default profiles
629    /// (GS `default_cmyk.icc`, FOGRA39) often approximate only coarsely.
630    pub fn output_intent_icc(&self) -> Option<&[u8]> {
631        self.output_intent_icc.as_deref()
632    }
633
634    /// Register the PDF's OutputIntent ICC profile as the default CMYK profile
635    /// in this document's ICC cache, replacing whatever was loaded from
636    /// `search_system_cmyk_profile`. Returns `true` when the profile was
637    /// present and registered.
638    pub fn apply_output_intent_as_default_cmyk(&mut self) -> bool {
639        let Some(bytes) = self.output_intent_icc.as_deref() else {
640            return false;
641        };
642        // Compute the profile hash and install it as the default CMYK BEFORE
643        // registering, so the proofing-chain logic in `register_profile` sees
644        // this profile as the OutputIntent (and skips chaining it through
645        // itself). Then enable proofing for any later ICCBased profiles —
646        // they will be color-managed through this OutputIntent so their
647        // colours converge with DeviceCMYK paints at the final
648        // `OutputIntent → sRGB` stage.
649        let hash = stet_graphics::icc::IccCache::hash_profile(bytes);
650        self.icc_cache.set_system_cmyk(bytes, hash);
651        self.icc_cache.set_proofing_enabled(true);
652        if self.icc_cache.register_profile(bytes).is_none() {
653            // Registration failed — undo the partial install so the cache
654            // doesn't claim a profile it can't actually use.
655            self.icc_cache.set_proofing_enabled(false);
656            return false;
657        }
658        // Pre-warm the sRGB → CMYK reverse transform so band renderers, which
659        // hold an `&IccCache`, can call `convert_rgb_to_cmyk_readonly` from the
660        // parallel CMYK buffer's non-CMYK painter path. Without this the
661        // readonly call returns `None` and the renderer falls back to the
662        // PLRM `(1-r, 1-g, 1-b, 0)` formula — which produces CMYK with no
663        // K and asymmetric C/M/Y, so a Lab/sRGB neutral gray no longer round-
664        // trips to a neutral gray when a downstream CMYK-group blend (e.g.
665        // GWG 22.1's ColorBurn form over a Lab BG) reads from the buffer.
666        // The viewer's `build_icc_cache_for_list` already calls this; doing
667        // it here keeps the PNG path and the viewer in lockstep.
668        self.icc_cache.prepare_reverse_cmyk();
669        // Also pre-build the `Lab → OI CMYK` samplers so Lab fills can take
670        // a direct ACE-style path through the OI's B2A LUTs, instead of going
671        // through Lab → sRGB → ICC reverse (which drifts under CMYK-group
672        // blends — same GWG 22.1 ColorBurn pattern as above).
673        self.icc_cache.prepare_lab_to_oi_cmyk();
674        true
675    }
676
677    /// Parse every entry in `/Catalog /OutputIntents` into round-tripable
678    /// records. Unlike [`output_intent_icc`](Self::output_intent_icc), which
679    /// is a renderer optimization that only captures CMYK profile bytes,
680    /// this preserves all output intents (any color space) with their full
681    /// metadata so the PDF writer can emit a faithful `/OutputIntents`
682    /// chain in the output catalog.
683    pub fn output_intents(&self) -> Vec<OutputIntentRecord> {
684        parse_output_intents_full(&self.resolver)
685    }
686
687    /// Access the resolver for arbitrary object lookups.
688    pub fn resolver(&self) -> &Resolver<'a> {
689        &self.resolver
690    }
691
692    /// Access page info list.
693    pub fn pages(&self) -> &[PageInfo] {
694        &self.pages
695    }
696
697    /// Document metadata: the trailer's `/Info` dict (title, author,
698    /// dates, etc.) and the catalog's `/Metadata` XMP stream.
699    ///
700    /// Parsed lazily on first call and cached. All fields are optional;
701    /// a document without an `/Info` dict still returns a value with
702    /// every field empty.
703    pub fn metadata(&self) -> &DocumentMetadata {
704        self.metadata_cache
705            .get_or_init(|| metadata::parse_document_metadata(&self.resolver))
706    }
707
708    /// Viewer preferences: how the document hints it should be displayed
709    /// (page layout, page mode, hide-toolbar, fit-window, print
710    /// preferences, etc.).
711    ///
712    /// Parsed lazily on first call and cached. Fields default per the
713    /// PDF spec when the corresponding entries are absent.
714    pub fn viewer_preferences(&self) -> &ViewerPreferences {
715        self.viewer_prefs_cache
716            .get_or_init(|| viewer_prefs::parse_viewer_preferences(&self.resolver))
717    }
718
719    /// Document outline (bookmarks) as a tree of [`OutlineItem`]s.
720    ///
721    /// Returns an empty slice if the document has no outline. Parsed
722    /// lazily on first call and cached. Cycles, broken `/First`/`/Next`
723    /// chains, and pathological depth are tolerated by hard caps;
724    /// each truncation pushes a warning visible through
725    /// [`parse_warnings`](Self::parse_warnings).
726    pub fn outline(&self) -> &[OutlineItem] {
727        self.outline_cache.get_or_init(|| {
728            outline::parse_outline_tree(&self.resolver, &self.pages, &self.warnings)
729        })
730    }
731
732    /// All named destinations in the document, merged from both
733    /// `/Catalog /Dests` (legacy) and `/Catalog /Names /Dests` (name
734    /// tree). Legacy entries take precedence on key conflict per
735    /// ISO 32000-2 §12.3.2.3.
736    ///
737    /// Parsed lazily on first call and cached. Returns an empty map
738    /// when neither source is present.
739    pub fn destinations(&self) -> &HashMap<String, Destination> {
740        self.destinations_cache
741            .get_or_init(|| destination::parse_named_destinations(&self.resolver, &self.pages))
742    }
743
744    /// Resolve a named destination by name to its explicit
745    /// destination.
746    ///
747    /// Looks up the document's full name table (legacy + name tree).
748    /// If the looked-up entry is itself another named destination
749    /// (legal but unusual), the chain is **not** followed — the
750    /// caller receives the raw `NamedDest`. This avoids cycles
751    /// without bookkeeping.
752    pub fn resolve_named_destination(&self, name: &str) -> Option<Destination> {
753        self.destinations().get(name).cloned()
754    }
755
756    /// Annotations attached to `page` (0-based).
757    ///
758    /// Returns an empty slice when the page has no annotations.
759    /// Parsed lazily on first call **per page** and cached, so a
760    /// 1000-page document with annotations only on a handful of
761    /// pages doesn't pay to parse the rest.
762    ///
763    /// Returns `Err(PdfError::PageOutOfRange)` if `page >= page_count()`.
764    pub fn page_annotations(&self, page: usize) -> Result<&[Annotation], PdfError> {
765        if page >= self.pages.len() {
766            return Err(PdfError::PageOutOfRange(page, self.pages.len()));
767        }
768        let cell = &self.page_annotations_cache[page];
769        let annots = cell.get_or_init(|| {
770            annotations::parse_page_annotations(&self.resolver, &self.pages, page, &self.warnings)
771        });
772        Ok(annots.as_slice())
773    }
774
775    /// AcroForm — interactive form catalog with field tree, default
776    /// appearance, calculation order, and signature flags.
777    ///
778    /// Returns `None` when the document has no `/AcroForm` (most PDFs
779    /// don't). Parsed lazily on first call and cached.
780    ///
781    /// Each terminal [`FormField`] carries the object numbers of its
782    /// widget annotations
783    /// ([`FormField::widget_obj_nums`](crate::FormField)); cross-link
784    /// with [`page_annotations`](Self::page_annotations) to fetch
785    /// renderable widget data.
786    pub fn form(&self) -> Option<&FormCatalog> {
787        self.form_cache
788            .get_or_init(|| form_fields::parse_acroform(&self.resolver, &self.warnings))
789            .as_ref()
790    }
791
792    /// Parse-time warnings accumulated by the structural accessors.
793    ///
794    /// Outline cycles, dropped annotations (missing `/Rect`),
795    /// form-field tree truncations, and similar recoverable issues
796    /// are surfaced here. The list grows as accessors are called for
797    /// the first time; cached subsequent calls don't re-emit.
798    ///
799    /// Returns a borrow of the underlying slice — drop the returned
800    /// `Ref` before calling any other accessor that could push more
801    /// warnings (e.g. iterating with `for w in doc.parse_warnings().iter()`
802    /// is fine; calling `doc.outline()` mid-iteration is not).
803    pub fn parse_warnings(&self) -> std::cell::Ref<'_, [ParseWarning]> {
804        self.warnings.borrow_slice()
805    }
806
807    /// Page geometry for a page (0-based) — all five PDF page boxes
808    /// (MediaBox, CropBox, BleedBox, TrimBox, ArtBox) plus rotation,
809    /// user unit, and presentation hints.
810    ///
811    /// Returns `Err(PdfError::PageOutOfRange)` if `page >= page_count()`.
812    pub fn page_boxes(&self, page: usize) -> Result<PageBoxes, PdfError> {
813        page_boxes::parse_page_boxes(&self.resolver, &self.pages, page)
814            .ok_or(PdfError::PageOutOfRange(page, self.pages.len()))
815    }
816
817    /// All file attachments declared in the catalog's
818    /// `/Names /EmbeddedFiles` name tree, keyed by attachment name.
819    ///
820    /// Parsed lazily on first call and cached. Returns an empty map
821    /// when the document has no embedded files. Use
822    /// [`embedded_file_bytes`](Self::embedded_file_bytes) to read the
823    /// underlying bytes of an attachment on demand.
824    pub fn embedded_files(&self) -> &HashMap<String, EmbeddedFile> {
825        self.embedded_files_cache
826            .get_or_init(|| embedded_files::parse_embedded_files(&self.resolver))
827    }
828
829    /// Read the decompressed bytes of a named embedded file.
830    ///
831    /// Returns `Err(PdfError::Other(...))` if the name is unknown.
832    pub fn embedded_file_bytes(&self, name: &str) -> Result<Vec<u8>, PdfError> {
833        let ef = self
834            .embedded_files()
835            .get(name)
836            .ok_or_else(|| PdfError::Other(format!("embedded file not found: {name}")))?;
837        embedded_files::decode_embedded_file_stream(
838            &self.resolver,
839            ef.stream_obj_num,
840            ef.stream_gen_num,
841        )
842    }
843
844    /// All Optional Content Groups (layers) declared by the document.
845    ///
846    /// Each [`Layer`] carries the OCG's display name, intent, lock
847    /// state, full `/Usage` sub-dict, and its initial visibility under
848    /// the default configuration. The hierarchy (`/Order`), alternate
849    /// configurations, and runtime visibility overrides land in later
850    /// phases of the layers API.
851    ///
852    /// Returns an empty slice when the document has no `/OCProperties`.
853    /// Parsed lazily on first call and cached.
854    pub fn layers(&self) -> &[Layer] {
855        self.layers_cache
856            .get_or_init(|| layers::metadata::parse_layers(&self.resolver, &self.warnings))
857            .as_slice()
858    }
859
860    /// Look up a single layer by its OCG object number.
861    ///
862    /// Useful when the caller already has an `ocg_id` from a display
863    /// list `OcgGroup` element and wants the layer's metadata.
864    pub fn layer(&self, ocg_id: u32) -> Option<&Layer> {
865        self.layers().iter().find(|l| l.ocg_id == ocg_id)
866    }
867
868    /// All layer configurations declared by the document.
869    ///
870    /// Index 0 is always the default configuration (`/OCProperties /D`);
871    /// indices 1..N are the entries of `/OCProperties /Configs` in the
872    /// order they appear. Returns an empty slice when the document has
873    /// no `/OCProperties`.
874    ///
875    /// Parsed lazily on first call and cached.
876    pub fn configurations(&self) -> &[Configuration] {
877        self.configurations_cache
878            .get_or_init(|| {
879                layers::configuration::parse_configurations(&self.resolver, &self.warnings)
880            })
881            .as_slice()
882    }
883
884    /// The default configuration (`/OCProperties /D`).
885    ///
886    /// Returns `None` when the document has no `/OCProperties` at all.
887    pub fn default_configuration(&self) -> Option<&Configuration> {
888        self.configurations().first()
889    }
890
891    /// Look up a configuration by index — `0` for the default, `1..N`
892    /// for alternates in the order they appear in `/Configs`.
893    pub fn configuration(&self, index: usize) -> Option<&Configuration> {
894        self.configurations().get(index)
895    }
896
897    /// The default configuration's `/Order` hierarchy.
898    ///
899    /// Convenience for layer-panel UIs that want the tree without
900    /// traversing through [`default_configuration`](Self::default_configuration).
901    /// Returns an empty tree when the document has no `/OCProperties`
902    /// or no `/Order` on the default config.
903    pub fn layer_tree(&self) -> LayerTree {
904        self.default_configuration()
905            .map(|c| c.order.clone())
906            .unwrap_or_default()
907    }
908
909    /// Build a [`LayerSet`] for rendering under a specific
910    /// [`RenderIntent`].
911    ///
912    /// Starts from the document's default configuration (every layer
913    /// at its `default_visible` state) and applies every `/AS`
914    /// automatic-state rule whose `/Event` matches the requested
915    /// intent. Pass the result to
916    /// [`render_page_to_rgba_with_layers`](Self::render_page_to_rgba_with_layers)
917    /// (or any other consumer of `LayerSet`) to honour
918    /// "print-only" / "view-only" / "export-only" layer hints in the
919    /// document.
920    pub fn layer_set_for(&self, intent: RenderIntent) -> LayerSet {
921        layers::layer_set_for(self, intent)
922    }
923}
924
925/// Parse the default OFF set from the catalog's OCProperties.
926/// Returns a set of object numbers for OCGs that are OFF by default.
927/// OCGs not listed in either /ON or /OFF are considered ON (PDF spec default).
928fn parse_ocg_off(resolver: &Resolver) -> HashSet<u32> {
929    let mut off = HashSet::new();
930
931    // Get catalog — try trailer /Root first, fall back to scanning if it
932    // doesn't look like a catalog (corrupt incremental updates can swap
933    // /Root and /Info, leaving Root pointing at the Info dict).
934    let mut catalog_owned;
935    let catalog_dict = if let Some(root_ref) = resolver.trailer().get_ref(b"Root") {
936        if let Ok(c) = resolver.resolve(root_ref.0, root_ref.1) {
937            catalog_owned = c;
938            match catalog_owned.as_dict() {
939                Some(d) if d.get(b"OCProperties").is_some() => d,
940                _ => match find_catalog(resolver) {
941                    Some(c) => {
942                        catalog_owned = c;
943                        catalog_owned.as_dict().unwrap()
944                    }
945                    None => return off,
946                },
947            }
948        } else {
949            return off;
950        }
951    } else {
952        return off;
953    };
954
955    // Get OCProperties -> D (default configuration) -> OFF array
956    let oc_props = match catalog_dict.get(b"OCProperties") {
957        Some(obj) => match resolver.deref(obj) {
958            Ok(o) => o,
959            Err(_) => return off,
960        },
961        None => return off,
962    };
963    let oc_dict = match oc_props.as_dict() {
964        Some(d) => d,
965        None => return off,
966    };
967    let d_obj = match oc_dict.get(b"D") {
968        Some(obj) => match resolver.deref(obj) {
969            Ok(o) => o,
970            Err(_) => return off,
971        },
972        None => return off,
973    };
974    let d_dict = match d_obj.as_dict() {
975        Some(d) => d,
976        None => return off,
977    };
978
979    // Collect object numbers from /OFF array (may be an indirect reference)
980    if let Some(off_obj) = d_dict.get(b"OFF") {
981        let off_resolved = resolver.deref(off_obj).unwrap_or_else(|_| off_obj.clone());
982        if let Some(off_arr) = off_resolved.as_array() {
983            for obj in off_arr {
984                if let Some((num, _gen)) = obj.as_ref() {
985                    off.insert(num);
986                }
987            }
988        }
989    }
990
991    off
992}
993
994/// Extract the decompressed ICC profile bytes from the first PDF/X
995/// OutputIntent whose `/DestOutputProfile` is a CMYK ICC stream.
996///
997/// PDF/X files declare their intended CMYK rendering profile (e.g. "ISO
998/// Coated v2 300% (ECI)") via `/Catalog/OutputIntents` with an embedded
999/// `/DestOutputProfile` stream. Using that profile at render time matches
1000/// the author's colour expectations; the system-default profiles used as
1001/// fallback (GS `default_cmyk.icc`, FOGRA39) only approximate it.
1002fn parse_output_intent_icc(resolver: &Resolver) -> Option<Vec<u8>> {
1003    let mut catalog_owned;
1004    let catalog_dict = if let Some(root_ref) = resolver.trailer().get_ref(b"Root") {
1005        if let Ok(c) = resolver.resolve(root_ref.0, root_ref.1) {
1006            catalog_owned = c;
1007            match catalog_owned.as_dict() {
1008                Some(d) if d.get(b"OutputIntents").is_some() => d,
1009                _ => {
1010                    catalog_owned = find_catalog(resolver)?;
1011                    catalog_owned.as_dict()?
1012                }
1013            }
1014        } else {
1015            catalog_owned = find_catalog(resolver)?;
1016            catalog_owned.as_dict()?
1017        }
1018    } else {
1019        catalog_owned = find_catalog(resolver)?;
1020        catalog_owned.as_dict()?
1021    };
1022
1023    let intents_obj = resolver.deref(catalog_dict.get(b"OutputIntents")?).ok()?;
1024    let intents_arr = intents_obj.as_array()?;
1025    for entry in intents_arr {
1026        let intent = match resolver.deref(entry) {
1027            Ok(o) => o,
1028            Err(_) => continue,
1029        };
1030        let Some(intent_dict) = intent.as_dict() else {
1031            continue;
1032        };
1033        let Some(profile_obj) = intent_dict.get(b"DestOutputProfile") else {
1034            continue;
1035        };
1036        let Ok(bytes) = resolver.stream_data_from_obj(profile_obj) else {
1037            continue;
1038        };
1039        // ICC header: color space at offset 16, 'acsp' magic at offset 36.
1040        if bytes.len() >= 40 && &bytes[36..40] == b"acsp" && &bytes[16..20] == b"CMYK" {
1041            return Some(bytes);
1042        }
1043    }
1044    None
1045}
1046
1047/// Parse every entry in `/Catalog /OutputIntents` into
1048/// [`OutputIntentRecord`]s, preserving all the descriptive metadata and
1049/// any color space (Gray / RGB / CMYK / Lab) of the embedded ICC profile.
1050/// The PDF writer uses this to emit a faithful `/OutputIntents` chain
1051/// during PDF→PDF round-tripping.
1052fn parse_output_intents_full(resolver: &Resolver) -> Vec<OutputIntentRecord> {
1053    // Walk the same path as `parse_output_intent_icc` to find the
1054    // catalog dict containing /OutputIntents. Returns Vec::new() when no
1055    // intents are declared.
1056    let catalog_obj = match resolver.trailer().get_ref(b"Root") {
1057        Some(root_ref) => match resolver.resolve(root_ref.0, root_ref.1) {
1058            Ok(c)
1059                if c.as_dict()
1060                    .is_some_and(|d| d.get(b"OutputIntents").is_some()) =>
1061            {
1062                c
1063            }
1064            _ => match find_catalog(resolver) {
1065                Some(c) => c,
1066                None => return Vec::new(),
1067            },
1068        },
1069        None => match find_catalog(resolver) {
1070            Some(c) => c,
1071            None => return Vec::new(),
1072        },
1073    };
1074    let Some(catalog_dict) = catalog_obj.as_dict() else {
1075        return Vec::new();
1076    };
1077    let Some(intents_ref) = catalog_dict.get(b"OutputIntents") else {
1078        return Vec::new();
1079    };
1080    let Ok(intents_obj) = resolver.deref(intents_ref) else {
1081        return Vec::new();
1082    };
1083    let Some(intents_arr) = intents_obj.as_array() else {
1084        return Vec::new();
1085    };
1086    let mut out = Vec::new();
1087    for entry in intents_arr {
1088        let intent = match resolver.deref(entry) {
1089            Ok(o) => o,
1090            Err(_) => continue,
1091        };
1092        let Some(intent_dict) = intent.as_dict() else {
1093            continue;
1094        };
1095        let subtype = intent_dict
1096            .get_name(b"S")
1097            .map(|n| n.to_vec())
1098            .unwrap_or_else(|| b"GTS_PDFX".to_vec());
1099        let (profile_bytes, n) = match intent_dict.get(b"DestOutputProfile") {
1100            Some(profile_obj) => match resolver.stream_data_from_obj(profile_obj) {
1101                Ok(bytes) if bytes.len() >= 40 && &bytes[36..40] == b"acsp" => {
1102                    let n = match &bytes[16..20] {
1103                        b"GRAY" => 1,
1104                        b"RGB " => 3,
1105                        b"CMYK" => 4,
1106                        b"Lab " => 3,
1107                        _ => 3,
1108                    };
1109                    (Some(std::sync::Arc::new(bytes)), n)
1110                }
1111                _ => (None, 0),
1112            },
1113            None => (None, 0),
1114        };
1115        let get_string = |key: &[u8]| -> Option<Vec<u8>> {
1116            intent_dict.get(key).and_then(|o| match resolver.deref(o) {
1117                Ok(PdfObj::Str(s)) => Some(s),
1118                _ => None,
1119            })
1120        };
1121        out.push(OutputIntentRecord {
1122            subtype,
1123            output_condition_identifier: get_string(b"OutputConditionIdentifier"),
1124            output_condition: get_string(b"OutputCondition"),
1125            registry_name: get_string(b"RegistryName"),
1126            info: get_string(b"Info"),
1127            dest_output_profile: profile_bytes,
1128            n,
1129        });
1130    }
1131    out
1132}
1133
1134/// Scan all objects to find the real Catalog dict (has /Type /Catalog).
1135/// Used when the trailer's /Root points to the wrong object.
1136pub(crate) fn find_catalog(resolver: &Resolver) -> Option<PdfObj> {
1137    let xref_len = resolver.xref_len();
1138    for obj_num in 0..xref_len as u32 {
1139        if let Ok(obj) = resolver.resolve(obj_num, 0) {
1140            if let Some(dict) = obj.as_dict() {
1141                if dict.get_name(b"Type") == Some(b"Catalog") && dict.get(b"Pages").is_some() {
1142                    return Some(obj);
1143                }
1144            }
1145        }
1146    }
1147    None
1148}
1149
1150/// Check for `%PDF-` header within the first 1024 bytes.
1151/// The PDF spec (§7.5.2) allows data before the header.
1152fn has_pdf_header(data: &[u8]) -> bool {
1153    let search_range = data.len().min(1024);
1154    data[..search_range].windows(5).any(|w| w == b"%PDF-")
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159    use super::*;
1160
1161    #[test]
1162    fn not_a_pdf() {
1163        let result = PdfDocument::from_bytes(b"not a pdf");
1164        assert!(matches!(result, Err(PdfError::NotAPdf)));
1165    }
1166
1167    #[test]
1168    fn parse_minimal_pdf() {
1169        let pdf = build_minimal_pdf();
1170        let doc = PdfDocument::from_bytes(&pdf).unwrap();
1171        assert_eq!(doc.page_count(), 1);
1172
1173        let (w, h) = doc.page_size(0).unwrap();
1174        assert_eq!(w, 612.0);
1175        assert_eq!(h, 792.0);
1176    }
1177
1178    #[test]
1179    fn page_out_of_range() {
1180        let pdf = build_minimal_pdf();
1181        let doc = PdfDocument::from_bytes(&pdf).unwrap();
1182        assert!(matches!(
1183            doc.page_size(5),
1184            Err(PdfError::PageOutOfRange(5, 1))
1185        ));
1186    }
1187
1188    #[test]
1189    fn page_contents_empty() {
1190        let pdf = build_minimal_pdf();
1191        let doc = PdfDocument::from_bytes(&pdf).unwrap();
1192        let contents = doc.page_contents(0).unwrap();
1193        // Our minimal PDF has no content stream
1194        assert!(contents.is_empty());
1195    }
1196
1197    #[test]
1198    #[ignore]
1199    fn dump_display_list() {
1200        use stet_fonts::geometry::PsPath;
1201        use stet_graphics::display_list::{DisplayElement, DisplayList};
1202
1203        fn path_bbox(path: &PsPath) -> String {
1204            use stet_fonts::geometry::PathSegment;
1205            let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
1206            for seg in &path.segments {
1207                let pts: Vec<(f64, f64)> = match seg {
1208                    PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => vec![(*x, *y)],
1209                    PathSegment::CurveTo {
1210                        x1,
1211                        y1,
1212                        x2,
1213                        y2,
1214                        x3,
1215                        y3,
1216                    } => vec![(*x1, *y1), (*x2, *y2), (*x3, *y3)],
1217                    PathSegment::ClosePath => vec![],
1218                };
1219                for (px, py) in pts {
1220                    x0 = x0.min(px);
1221                    y0 = y0.min(py);
1222                    x1 = x1.max(px);
1223                    y1 = y1.max(py);
1224                }
1225            }
1226            format!("bbox=({:.0},{:.0},{:.0},{:.0})", x0, y0, x1, y1)
1227        }
1228
1229        fn dump(list: &DisplayList, depth: usize) {
1230            let indent = "  ".repeat(depth);
1231            for (i, elem) in list.elements().iter().enumerate() {
1232                match elem {
1233                    DisplayElement::Fill { path, params } => {
1234                        let c = &params.color;
1235                        let cmyk_str = if let Some((c2, m, y, k)) = params.color.native_cmyk {
1236                            format!(" cmyk=({:.2},{:.2},{:.2},{:.2})", c2, m, y, k)
1237                        } else {
1238                            String::new()
1239                        };
1240                        eprintln!(
1241                            "{indent}[{i}] Fill rgb=({:.2},{:.2},{:.2}){} op={} opm={} ch=0x{:x} a={:.2} {}",
1242                            c.r,
1243                            c.g,
1244                            c.b,
1245                            cmyk_str,
1246                            params.overprint,
1247                            params.overprint_mode,
1248                            params.painted_channels,
1249                            params.alpha,
1250                            path_bbox(path)
1251                        );
1252                    }
1253                    DisplayElement::Stroke { path, params } => {
1254                        let c = &params.color;
1255                        eprintln!(
1256                            "{indent}[{i}] Stroke rgb=({:.2},{:.2},{:.2}) {}",
1257                            c.r,
1258                            c.g,
1259                            c.b,
1260                            path_bbox(path)
1261                        );
1262                    }
1263                    DisplayElement::Clip { path, .. } => {
1264                        eprintln!("{indent}[{i}] Clip {}", path_bbox(path))
1265                    }
1266                    DisplayElement::InitClip => eprintln!("{indent}[{i}] InitClip"),
1267                    DisplayElement::Image { params, .. } => {
1268                        eprintln!("{indent}[{i}] Image {}x{}", params.width, params.height);
1269                    }
1270                    DisplayElement::ErasePage => eprintln!("{indent}[{i}] ErasePage"),
1271                    DisplayElement::AxialShading { params } => {
1272                        eprintln!(
1273                            "{indent}[{i}] AxialShading cs={:?} stops={}",
1274                            params.color_space,
1275                            params.color_stops.len()
1276                        );
1277                    }
1278                    DisplayElement::RadialShading { params } => {
1279                        eprintln!(
1280                            "{indent}[{i}] RadialShading cs={:?} stops={} ext=({},{}) c0=({:.1},{:.1}) r0={:.1} c1=({:.1},{:.1}) r1={:.1} bbox={:?} op={} ch=0x{:x}",
1281                            params.color_space,
1282                            params.color_stops.len(),
1283                            params.extend_start,
1284                            params.extend_end,
1285                            params.x0,
1286                            params.y0,
1287                            params.r0,
1288                            params.x1,
1289                            params.y1,
1290                            params.r1,
1291                            params.bbox,
1292                            params.overprint,
1293                            params.painted_channels
1294                        );
1295                        // Print first and last stop
1296                        if let Some(first) = params.color_stops.first() {
1297                            eprintln!(
1298                                "{indent}  stop[0]: pos={:.3} rgb=({:.3},{:.3},{:.3}) raw={:?}",
1299                                first.position,
1300                                first.color.r,
1301                                first.color.g,
1302                                first.color.b,
1303                                first.raw_components
1304                            );
1305                        }
1306                        if let Some(last) = params.color_stops.last() {
1307                            eprintln!(
1308                                "{indent}  stop[{}]: pos={:.3} rgb=({:.3},{:.3},{:.3}) raw={:?}",
1309                                params.color_stops.len() - 1,
1310                                last.position,
1311                                last.color.r,
1312                                last.color.g,
1313                                last.color.b,
1314                                last.raw_components
1315                            );
1316                        }
1317                        // Print mid stop
1318                        let mid = params.color_stops.len() / 2;
1319                        if mid > 0 && mid < params.color_stops.len() - 1 {
1320                            let s = &params.color_stops[mid];
1321                            eprintln!(
1322                                "{indent}  stop[{mid}]: pos={:.3} rgb=({:.3},{:.3},{:.3}) raw={:?}",
1323                                s.position, s.color.r, s.color.g, s.color.b, s.raw_components
1324                            );
1325                        }
1326                    }
1327                    DisplayElement::MeshShading { .. } => eprintln!("{indent}[{i}] MeshShading"),
1328                    DisplayElement::PatchShading { .. } => eprintln!("{indent}[{i}] PatchShading"),
1329                    DisplayElement::PatternFill { .. } => eprintln!("{indent}[{i}] PatternFill"),
1330                    DisplayElement::Text { .. } => eprintln!("{indent}[{i}] Text"),
1331                    DisplayElement::Group { elements, params } => {
1332                        eprintln!(
1333                            "{indent}[{i}] Group iso={} ko={} blend={} a={:.2} bbox=({:.0},{:.0},{:.0},{:.0}) children={}",
1334                            params.isolated,
1335                            params.knockout,
1336                            params.blend_mode,
1337                            params.alpha,
1338                            params.bbox[0],
1339                            params.bbox[1],
1340                            params.bbox[2],
1341                            params.bbox[3],
1342                            elements.len()
1343                        );
1344                        dump(elements, depth + 1);
1345                    }
1346                    DisplayElement::SoftMasked {
1347                        mask,
1348                        content,
1349                        params,
1350                        ..
1351                    } => {
1352                        eprintln!(
1353                            "{indent}[{i}] SoftMasked {:?} mask={} content={}",
1354                            params.subtype,
1355                            mask.len(),
1356                            content.len()
1357                        );
1358                        eprintln!("{indent}  MASK:");
1359                        dump(mask, depth + 2);
1360                        eprintln!("{indent}  CONTENT:");
1361                        dump(content, depth + 2);
1362                    }
1363                    DisplayElement::OcgGroup {
1364                        elements,
1365                        visibility,
1366                    } => {
1367                        eprintln!(
1368                            "{indent}[{i}] OcgGroup vis={:?} children={}",
1369                            visibility,
1370                            elements.len()
1371                        );
1372                        dump(elements, depth + 1);
1373                    }
1374                    _ => {}
1375                }
1376            }
1377        }
1378
1379        let data = std::fs::read("../../pdf_samples/PDFX-ready_Output-Test_X4.pdf").unwrap();
1380        let doc = PdfDocument::from_bytes(&data).unwrap();
1381        let dl = doc.render_page(0, 72.0).unwrap();
1382        eprintln!("=== Display list: {} top-level elements ===", dl.len());
1383        dump(&dl, 0);
1384    }
1385
1386    /// Build a minimal PDF with a 3-node outline tree: a parent
1387    /// "Chapter 1" with two children "Section 1.1" and "Section 1.2".
1388    /// Used to exercise the outline walker end-to-end.
1389    fn build_pdf_with_outline() -> Vec<u8> {
1390        let mut pdf = Vec::new();
1391        pdf.extend(b"%PDF-1.4\n");
1392
1393        let mut offsets: Vec<usize> = Vec::new();
1394        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
1395            offsets.push(buf.len());
1396            buf.extend(body);
1397        };
1398
1399        // 1: Catalog (with /Outlines ref)
1400        push_obj(
1401            &mut pdf,
1402            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>\nendobj\n",
1403        );
1404        // 2: Pages
1405        push_obj(
1406            &mut pdf,
1407            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
1408        );
1409        // 3: Page
1410        push_obj(
1411            &mut pdf,
1412            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
1413        );
1414        // 4: Outlines (root): /First and /Last both point to obj 5
1415        push_obj(
1416            &mut pdf,
1417            b"4 0 obj\n<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count 3 >>\nendobj\n",
1418        );
1419        // 5: Outline "Chapter 1" (open, two children)
1420        push_obj(
1421            &mut pdf,
1422            b"5 0 obj\n<< /Title (Chapter 1) /Parent 4 0 R /First 6 0 R /Last 7 0 R \
1423              /Count 2 /Dest [3 0 R /Fit] /F 2 >>\nendobj\n",
1424        );
1425        // 6: Outline "Section 1.1"
1426        push_obj(
1427            &mut pdf,
1428            b"6 0 obj\n<< /Title (Section 1.1) /Parent 5 0 R /Next 7 0 R \
1429              /Dest [3 0 R /XYZ 72 700 1.0] /C [0.2 0.3 0.4] >>\nendobj\n",
1430        );
1431        // 7: Outline "Section 1.2"
1432        push_obj(
1433            &mut pdf,
1434            b"7 0 obj\n<< /Title (Section 1.2) /Parent 5 0 R /Prev 6 0 R \
1435              /A << /S /URI /URI (https://example.com) >> /F 1 >>\nendobj\n",
1436        );
1437
1438        let xref_offset = pdf.len();
1439        pdf.extend(b"xref\n0 8\n");
1440        pdf.extend(b"0000000000 65535 f\r\n");
1441        for off in &offsets {
1442            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
1443        }
1444        pdf.extend(b"trailer\n<< /Size 8 /Root 1 0 R >>\n");
1445        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
1446
1447        pdf
1448    }
1449
1450    #[test]
1451    fn outline_basic_tree() {
1452        let pdf = build_pdf_with_outline();
1453        let doc = PdfDocument::from_bytes(&pdf).unwrap();
1454        let outline = doc.outline();
1455
1456        assert_eq!(outline.len(), 1, "expected one top-level entry");
1457        let chapter = &outline[0];
1458        assert_eq!(chapter.title, "Chapter 1");
1459        assert!(chapter.open, "Chapter 1 has /Count 2 (positive = open)");
1460        assert!(chapter.style.bold);
1461        assert!(!chapter.style.italic);
1462        assert_eq!(chapter.children.len(), 2);
1463
1464        let s11 = &chapter.children[0];
1465        assert_eq!(s11.title, "Section 1.1");
1466        assert!(s11.action.is_none());
1467        match &s11.destination {
1468            Some(crate::Destination::PageView { page, view }) => {
1469                assert_eq!(*page, Some(0));
1470                assert!(matches!(view, crate::ViewSpec::Xyz { .. }));
1471            }
1472            other => panic!("expected PageView destination, got {other:?}"),
1473        }
1474        assert_eq!(s11.color, Some([0.2, 0.3, 0.4]));
1475
1476        let s12 = &chapter.children[1];
1477        assert_eq!(s12.title, "Section 1.2");
1478        assert!(s12.style.italic && !s12.style.bold);
1479        match &s12.action {
1480            Some(crate::Action::Uri { uri, is_map }) => {
1481                assert_eq!(uri, "https://example.com");
1482                assert!(!is_map);
1483            }
1484            other => panic!("expected URI action, got {other:?}"),
1485        }
1486    }
1487
1488    #[test]
1489    fn outline_caches_across_calls() {
1490        let pdf = build_pdf_with_outline();
1491        let doc = PdfDocument::from_bytes(&pdf).unwrap();
1492        let a = doc.outline();
1493        let b = doc.outline();
1494        assert!(std::ptr::eq(a, b), "outline() must be cached");
1495    }
1496
1497    #[test]
1498    fn outline_empty_when_absent() {
1499        let pdf = build_minimal_pdf();
1500        let doc = PdfDocument::from_bytes(&pdf).unwrap();
1501        assert!(doc.outline().is_empty());
1502    }
1503
1504    /// Build a PDF with named destinations declared via the legacy
1505    /// `/Catalog /Dests` direct dict.
1506    fn build_pdf_with_legacy_dests() -> Vec<u8> {
1507        let mut pdf = Vec::new();
1508        pdf.extend(b"%PDF-1.4\n");
1509
1510        let mut offsets: Vec<usize> = Vec::new();
1511        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
1512            offsets.push(buf.len());
1513            buf.extend(body);
1514        };
1515
1516        // 1: Catalog with /Dests pointing at obj 4
1517        push_obj(
1518            &mut pdf,
1519            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Dests 4 0 R >>\nendobj\n",
1520        );
1521        // 2: Pages
1522        push_obj(
1523            &mut pdf,
1524            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
1525        );
1526        // 3: Page
1527        push_obj(
1528            &mut pdf,
1529            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
1530        );
1531        // 4: Legacy /Dests dict
1532        push_obj(
1533            &mut pdf,
1534            b"4 0 obj\n<< /Intro [3 0 R /Fit] /Glossary [3 0 R /XYZ 100 700 1.0] >>\nendobj\n",
1535        );
1536
1537        let xref_offset = pdf.len();
1538        pdf.extend(b"xref\n0 5\n");
1539        pdf.extend(b"0000000000 65535 f\r\n");
1540        for off in &offsets {
1541            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
1542        }
1543        pdf.extend(b"trailer\n<< /Size 5 /Root 1 0 R >>\n");
1544        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
1545
1546        pdf
1547    }
1548
1549    /// Build a PDF with named destinations declared via the modern
1550    /// `/Catalog /Names /Dests` name tree (flat leaf form).
1551    fn build_pdf_with_name_tree_dests() -> Vec<u8> {
1552        let mut pdf = Vec::new();
1553        pdf.extend(b"%PDF-1.4\n");
1554
1555        let mut offsets: Vec<usize> = Vec::new();
1556        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
1557            offsets.push(buf.len());
1558            buf.extend(body);
1559        };
1560
1561        // 1: Catalog with /Names dict
1562        push_obj(
1563            &mut pdf,
1564            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Names 4 0 R >>\nendobj\n",
1565        );
1566        // 2: Pages
1567        push_obj(
1568            &mut pdf,
1569            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
1570        );
1571        // 3: Page
1572        push_obj(
1573            &mut pdf,
1574            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
1575        );
1576        // 4: /Names dict pointing at /Dests name-tree root
1577        push_obj(&mut pdf, b"4 0 obj\n<< /Dests 5 0 R >>\nendobj\n");
1578        // 5: Name-tree leaf with two entries (sorted)
1579        push_obj(
1580            &mut pdf,
1581            b"5 0 obj\n<< /Names [(Alpha) [3 0 R /Fit] (Beta) [3 0 R /XYZ 50 500 0]] >>\nendobj\n",
1582        );
1583
1584        let xref_offset = pdf.len();
1585        pdf.extend(b"xref\n0 6\n");
1586        pdf.extend(b"0000000000 65535 f\r\n");
1587        for off in &offsets {
1588            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
1589        }
1590        pdf.extend(b"trailer\n<< /Size 6 /Root 1 0 R >>\n");
1591        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
1592
1593        pdf
1594    }
1595
1596    /// Build a PDF where the *same* destination name appears in both
1597    /// legacy /Dests and the name tree, with different targets — the
1598    /// legacy entry must win per spec.
1599    fn build_pdf_with_dest_conflict() -> Vec<u8> {
1600        let mut pdf = Vec::new();
1601        pdf.extend(b"%PDF-1.4\n");
1602
1603        let mut offsets: Vec<usize> = Vec::new();
1604        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
1605            offsets.push(buf.len());
1606            buf.extend(body);
1607        };
1608
1609        // 1: Catalog with both /Dests and /Names
1610        push_obj(
1611            &mut pdf,
1612            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Dests 4 0 R /Names 5 0 R >>\nendobj\n",
1613        );
1614        push_obj(
1615            &mut pdf,
1616            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
1617        );
1618        push_obj(
1619            &mut pdf,
1620            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
1621        );
1622        // 4: legacy /Dests — Conflict points at /Fit
1623        push_obj(&mut pdf, b"4 0 obj\n<< /Conflict [3 0 R /Fit] >>\nendobj\n");
1624        // 5: /Names with /Dests — Conflict points at /FitB (should be overridden)
1625        push_obj(&mut pdf, b"5 0 obj\n<< /Dests 6 0 R >>\nendobj\n");
1626        push_obj(
1627            &mut pdf,
1628            b"6 0 obj\n<< /Names [(Conflict) [3 0 R /FitB]] >>\nendobj\n",
1629        );
1630
1631        let xref_offset = pdf.len();
1632        pdf.extend(b"xref\n0 7\n");
1633        pdf.extend(b"0000000000 65535 f\r\n");
1634        for off in &offsets {
1635            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
1636        }
1637        pdf.extend(b"trailer\n<< /Size 7 /Root 1 0 R >>\n");
1638        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
1639
1640        pdf
1641    }
1642
1643    #[test]
1644    fn destinations_legacy_dict() {
1645        let pdf = build_pdf_with_legacy_dests();
1646        let doc = PdfDocument::from_bytes(&pdf).unwrap();
1647        let dests = doc.destinations();
1648        assert_eq!(dests.len(), 2);
1649        match dests.get("Intro") {
1650            Some(crate::Destination::PageView { page, view }) => {
1651                assert_eq!(*page, Some(0));
1652                assert_eq!(*view, crate::ViewSpec::Fit);
1653            }
1654            other => panic!("expected PageView for Intro, got {other:?}"),
1655        }
1656        assert!(dests.contains_key("Glossary"));
1657    }
1658
1659    #[test]
1660    fn destinations_name_tree() {
1661        let pdf = build_pdf_with_name_tree_dests();
1662        let doc = PdfDocument::from_bytes(&pdf).unwrap();
1663        let dests = doc.destinations();
1664        assert_eq!(dests.len(), 2);
1665        assert!(dests.contains_key("Alpha"));
1666        assert!(dests.contains_key("Beta"));
1667    }
1668
1669    #[test]
1670    fn destinations_legacy_overrides_name_tree() {
1671        let pdf = build_pdf_with_dest_conflict();
1672        let doc = PdfDocument::from_bytes(&pdf).unwrap();
1673        let dests = doc.destinations();
1674        assert_eq!(dests.len(), 1);
1675        match dests.get("Conflict") {
1676            Some(crate::Destination::PageView { view, .. }) => {
1677                assert_eq!(
1678                    *view,
1679                    crate::ViewSpec::Fit,
1680                    "legacy /Dests must override /Names /Dests"
1681                );
1682            }
1683            other => panic!("expected PageView, got {other:?}"),
1684        }
1685    }
1686
1687    #[test]
1688    fn destinations_caches_across_calls() {
1689        let pdf = build_pdf_with_legacy_dests();
1690        let doc = PdfDocument::from_bytes(&pdf).unwrap();
1691        let a = doc.destinations();
1692        let b = doc.destinations();
1693        assert!(std::ptr::eq(a, b), "destinations() must be cached");
1694    }
1695
1696    /// Build a one-page PDF with five annotations exercising the most
1697    /// commonly used subtypes: Link (URI), Text (sticky note),
1698    /// Highlight (markup with /QuadPoints), Square (interior color),
1699    /// FreeText (default appearance + quadding).
1700    fn build_pdf_with_annotations() -> Vec<u8> {
1701        let mut pdf = Vec::new();
1702        pdf.extend(b"%PDF-1.4\n");
1703
1704        let mut offsets: Vec<usize> = Vec::new();
1705        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
1706            offsets.push(buf.len());
1707            buf.extend(body);
1708        };
1709
1710        // 1: Catalog
1711        push_obj(
1712            &mut pdf,
1713            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
1714        );
1715        // 2: Pages
1716        push_obj(
1717            &mut pdf,
1718            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
1719        );
1720        // 3: Page with /Annots referencing 4..8
1721        push_obj(
1722            &mut pdf,
1723            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1724              /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >>\nendobj\n",
1725        );
1726        // 4: Link annotation with URI action
1727        push_obj(
1728            &mut pdf,
1729            b"4 0 obj\n<< /Type /Annot /Subtype /Link /Rect [72 720 540 740] \
1730              /Border [0 0 1] \
1731              /A << /S /URI /URI (https://example.com) >> >>\nendobj\n",
1732        );
1733        // 5: Text annotation (sticky note)
1734        push_obj(
1735            &mut pdf,
1736            b"5 0 obj\n<< /Type /Annot /Subtype /Text /Rect [100 600 120 620] \
1737              /Contents (A note) /Open true /Name /Comment /T (Scott) \
1738              /M (D:20260427120000Z) >>\nendobj\n",
1739        );
1740        // 6: Highlight markup
1741        push_obj(
1742            &mut pdf,
1743            b"6 0 obj\n<< /Type /Annot /Subtype /Highlight /Rect [72 500 300 520] \
1744              /QuadPoints [72 520 300 520 72 500 300 500] \
1745              /C [1.0 0.95 0.0] >>\nendobj\n",
1746        );
1747        // 7: Square shape with interior color
1748        push_obj(
1749            &mut pdf,
1750            b"7 0 obj\n<< /Type /Annot /Subtype /Square /Rect [200 400 300 450] \
1751              /IC [0.0 0.5 1.0] /C [0.0 0.0 0.0] /F 4 >>\nendobj\n",
1752        );
1753        // 8: FreeText
1754        push_obj(
1755            &mut pdf,
1756            b"8 0 obj\n<< /Type /Annot /Subtype /FreeText /Rect [72 300 300 350] \
1757              /Contents (Visible text) /DA (/Helv 10 Tf 0 g) /Q 1 \
1758              /IT /FreeTextCallout >>\nendobj\n",
1759        );
1760
1761        let xref_offset = pdf.len();
1762        pdf.extend(b"xref\n0 9\n");
1763        pdf.extend(b"0000000000 65535 f\r\n");
1764        for off in &offsets {
1765            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
1766        }
1767        pdf.extend(b"trailer\n<< /Size 9 /Root 1 0 R >>\n");
1768        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
1769
1770        pdf
1771    }
1772
1773    #[test]
1774    fn page_annotations_basic_subtypes() {
1775        let pdf = build_pdf_with_annotations();
1776        let doc = PdfDocument::from_bytes(&pdf).unwrap();
1777        let annots = doc.page_annotations(0).unwrap();
1778        assert_eq!(annots.len(), 5);
1779
1780        // Link
1781        let link = &annots[0];
1782        assert_eq!(link.kind, crate::AnnotationKind::Link);
1783        assert_eq!(link.rect, [72.0, 720.0, 540.0, 740.0]);
1784        match &link.kind_data {
1785            crate::AnnotationKindData::Link(l) => match &l.action {
1786                Some(crate::Action::Uri { uri, .. }) => {
1787                    assert_eq!(uri, "https://example.com");
1788                }
1789                other => panic!("expected Uri action, got {other:?}"),
1790            },
1791            other => panic!("expected Link kind data, got {other:?}"),
1792        }
1793
1794        // Text
1795        let text = &annots[1];
1796        assert_eq!(text.kind, crate::AnnotationKind::Text);
1797        assert_eq!(text.contents.as_deref(), Some("A note"));
1798        assert_eq!(text.title.as_deref(), Some("Scott"));
1799        match &text.kind_data {
1800            crate::AnnotationKindData::Text(t) => {
1801                assert!(t.open);
1802                assert_eq!(t.icon.as_deref(), Some("Comment"));
1803            }
1804            _ => panic!("expected Text kind"),
1805        }
1806        // Modified date should parse.
1807        assert!(matches!(
1808            text.modified,
1809            Some(crate::AnnotationDate::Date(_))
1810        ));
1811
1812        // Highlight
1813        let hl = &annots[2];
1814        assert_eq!(hl.kind, crate::AnnotationKind::Highlight);
1815        assert_eq!(
1816            hl.color,
1817            Some(crate::AnnotationColor::Rgb([1.0, 0.95, 0.0]))
1818        );
1819        match &hl.kind_data {
1820            crate::AnnotationKindData::Markup(m) => {
1821                assert_eq!(m.quad_points.len(), 1);
1822            }
1823            _ => panic!("expected Markup kind"),
1824        }
1825
1826        // Square
1827        let sq = &annots[3];
1828        assert_eq!(sq.kind, crate::AnnotationKind::Square);
1829        assert!(sq.flags.print);
1830        match &sq.kind_data {
1831            crate::AnnotationKindData::Shape(s) => {
1832                assert_eq!(
1833                    s.interior_color,
1834                    Some(crate::AnnotationColor::Rgb([0.0, 0.5, 1.0]))
1835                );
1836            }
1837            _ => panic!("expected Shape kind"),
1838        }
1839
1840        // FreeText
1841        let ft = &annots[4];
1842        assert_eq!(ft.kind, crate::AnnotationKind::FreeText);
1843        match &ft.kind_data {
1844            crate::AnnotationKindData::FreeText(f) => {
1845                assert_eq!(f.default_appearance.as_deref(), Some("/Helv 10 Tf 0 g"));
1846                assert_eq!(f.quadding, 1);
1847                assert_eq!(f.intent.as_deref(), Some("FreeTextCallout"));
1848            }
1849            _ => panic!("expected FreeText kind"),
1850        }
1851    }
1852
1853    #[test]
1854    fn page_annotations_caches_per_page() {
1855        let pdf = build_pdf_with_annotations();
1856        let doc = PdfDocument::from_bytes(&pdf).unwrap();
1857        let a = doc.page_annotations(0).unwrap();
1858        let b = doc.page_annotations(0).unwrap();
1859        assert!(std::ptr::eq(a, b), "page_annotations(0) must be cached");
1860    }
1861
1862    #[test]
1863    fn page_annotations_out_of_range() {
1864        let pdf = build_pdf_with_annotations();
1865        let doc = PdfDocument::from_bytes(&pdf).unwrap();
1866        assert!(doc.page_annotations(99).is_err());
1867    }
1868
1869    #[test]
1870    fn page_annotations_empty_when_absent() {
1871        let pdf = build_minimal_pdf();
1872        let doc = PdfDocument::from_bytes(&pdf).unwrap();
1873        let annots = doc.page_annotations(0).unwrap();
1874        assert!(annots.is_empty());
1875    }
1876
1877    /// Build a one-page PDF with a small AcroForm: a text field, a
1878    /// checkbox, a 2-button radio group, a combo box, and a
1879    /// container "shipping" with two terminal text-field children
1880    /// "shipping.street" and "shipping.zip".
1881    fn build_pdf_with_form() -> Vec<u8> {
1882        let mut pdf = Vec::new();
1883        pdf.extend(b"%PDF-1.4\n");
1884
1885        let mut offsets: Vec<usize> = Vec::new();
1886        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
1887            offsets.push(buf.len());
1888            buf.extend(body);
1889        };
1890
1891        // 1: Catalog with /AcroForm
1892        push_obj(
1893            &mut pdf,
1894            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n",
1895        );
1896        // 2: Pages
1897        push_obj(
1898            &mut pdf,
1899            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
1900        );
1901        // 3: Page (with /Annots referencing widgets 5,6,9,10,12)
1902        push_obj(
1903            &mut pdf,
1904            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1905              /Annots [5 0 R 6 0 R 9 0 R 10 0 R 12 0 R 14 0 R 15 0 R] >>\nendobj\n",
1906        );
1907        // 4: AcroForm dict (top-level fields = text, checkbox, radio, combo, shipping container)
1908        push_obj(
1909            &mut pdf,
1910            b"4 0 obj\n<< /Fields [5 0 R 6 0 R 7 0 R 11 0 R 13 0 R] \
1911              /NeedAppearances true /SigFlags 1 \
1912              /CO [(name)] /DA (/Helv 12 Tf 0 g) /Q 0 >>\nendobj\n",
1913        );
1914        // 5: Text field "name" (also its own widget)
1915        push_obj(
1916            &mut pdf,
1917            b"5 0 obj\n<< /T (name) /TU (Full Name) /FT /Tx /Ff 0 \
1918              /MaxLen 50 /V (Scott) /DV () \
1919              /Subtype /Widget /Rect [72 720 300 740] /Type /Annot >>\nendobj\n",
1920        );
1921        // 6: Checkbox "agree" (own widget)
1922        push_obj(
1923            &mut pdf,
1924            b"6 0 obj\n<< /T (agree) /FT /Btn /Ff 0 /V /Yes \
1925              /Subtype /Widget /Rect [72 700 90 718] /Type /Annot >>\nendobj\n",
1926        );
1927        // 7: Radio group "color" — non-widget parent with /Kids
1928        push_obj(
1929            &mut pdf,
1930            b"7 0 obj\n<< /T (color) /FT /Btn /Ff 49152 /V /Red \
1931              /Kids [9 0 R 10 0 R] /Opt [(Red) (Blue)] >>\nendobj\n",
1932        );
1933        //   bit 15 (NoToggleToOff) | bit 16 (Radio) | bit 17 cleared = 0xC000 = 49152
1934        // 9: Radio widget Red (child)
1935        push_obj(
1936            &mut pdf,
1937            b"9 0 obj\n<< /Parent 7 0 R /Subtype /Widget /Type /Annot \
1938              /Rect [72 680 90 698] /AS /Red >>\nendobj\n",
1939        );
1940        // 10: Radio widget Blue (child)
1941        push_obj(
1942            &mut pdf,
1943            b"10 0 obj\n<< /Parent 7 0 R /Subtype /Widget /Type /Annot \
1944              /Rect [100 680 118 698] /AS /Off >>\nendobj\n",
1945        );
1946        // 11: Combo box "country" (own widget)
1947        push_obj(
1948            &mut pdf,
1949            b"11 0 obj\n<< /T (country) /FT /Ch /Ff 131072 /V (US) \
1950              /Opt [[(US) (United States)] [(GB) (United Kingdom)]] \
1951              /Subtype /Widget /Rect [72 660 200 678] /Type /Annot >>\nendobj\n",
1952        );
1953        //   bit 18 = Combo = 0x20000 = 131072
1954        // 13: Container "shipping" (no /FT, has /Kids)
1955        push_obj(
1956            &mut pdf,
1957            b"13 0 obj\n<< /T (shipping) /Kids [14 0 R 15 0 R] >>\nendobj\n",
1958        );
1959        // 14: Text field "shipping.street" (own widget)
1960        push_obj(
1961            &mut pdf,
1962            b"14 0 obj\n<< /T (street) /Parent 13 0 R /FT /Tx /V (123 Main) \
1963              /Subtype /Widget /Rect [72 640 300 658] /Type /Annot >>\nendobj\n",
1964        );
1965        // 15: Text field "shipping.zip" (own widget)
1966        push_obj(
1967            &mut pdf,
1968            b"15 0 obj\n<< /T (zip) /Parent 13 0 R /FT /Tx /V (12345) \
1969              /Subtype /Widget /Rect [72 620 200 638] /Type /Annot >>\nendobj\n",
1970        );
1971
1972        let xref_offset = pdf.len();
1973        // We have objects 1..=15 except 8 and 12. Use a simple "all
1974        // present" xref sized to 16 entries; missing slots get free
1975        // entries pointing nowhere, which the resolver tolerates.
1976        let real_offsets: Vec<usize> = offsets;
1977        // Build a map by inserting each real offset at its declared
1978        // object number index.
1979        let mut entries: Vec<Option<usize>> = vec![None; 16];
1980        // The offsets vector was pushed in declaration order; we
1981        // declared 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 13, 14, 15.
1982        let declared = [1u32, 2, 3, 4, 5, 6, 7, 9, 10, 11, 13, 14, 15];
1983        for (i, &n) in declared.iter().enumerate() {
1984            entries[n as usize] = Some(real_offsets[i]);
1985        }
1986        pdf.extend(b"xref\n0 16\n");
1987        pdf.extend(b"0000000000 65535 f\r\n");
1988        for entry in entries.iter().skip(1) {
1989            match entry {
1990                Some(off) => pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes()),
1991                None => pdf.extend(b"0000000000 65535 f\r\n"),
1992            }
1993        }
1994        pdf.extend(b"trailer\n<< /Size 16 /Root 1 0 R >>\n");
1995        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
1996
1997        pdf
1998    }
1999
2000    #[test]
2001    fn form_basic_field_tree() {
2002        let pdf = build_pdf_with_form();
2003        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2004        let form = doc.form().expect("AcroForm should be present");
2005
2006        assert!(form.need_appearances);
2007        assert!(form.sig_flags.signatures_exist);
2008        assert!(!form.sig_flags.append_only);
2009        assert_eq!(form.calculation_order, vec!["name".to_string()]);
2010        assert_eq!(form.default_appearance.as_deref(), Some("/Helv 12 Tf 0 g"));
2011
2012        // Top-level fields: name, agree, color, country, shipping
2013        assert_eq!(form.fields.len(), 5);
2014
2015        let name = &form.fields[0];
2016        assert_eq!(name.name, "name");
2017        assert_eq!(name.alternate_name.as_deref(), Some("Full Name"));
2018        match &name.kind {
2019            crate::FieldKind::Text(t) => {
2020                assert_eq!(t.max_length, Some(50));
2021                assert!(!t.multiline && !t.password);
2022            }
2023            _ => panic!("name should be a Text field"),
2024        }
2025        assert_eq!(name.value, crate::FieldValue::Text("Scott".to_string()));
2026        // Self-as-widget: name field is its own widget
2027        assert_eq!(name.widget_obj_nums.len(), 1);
2028
2029        let agree = &form.fields[1];
2030        match &agree.kind {
2031            crate::FieldKind::Button(b) => {
2032                assert_eq!(b.button_type, crate::ButtonType::Checkbox);
2033            }
2034            _ => panic!("agree should be a Button"),
2035        }
2036        assert_eq!(agree.value, crate::FieldValue::Name("Yes".to_string()));
2037
2038        let color = &form.fields[2];
2039        assert_eq!(color.name, "color");
2040        match &color.kind {
2041            crate::FieldKind::Button(b) => {
2042                assert_eq!(b.button_type, crate::ButtonType::Radio);
2043                assert!(b.no_toggle_to_off);
2044                assert_eq!(b.options, vec!["Red".to_string(), "Blue".to_string()]);
2045            }
2046            _ => panic!("color should be a Radio group"),
2047        }
2048        // Two widget children attached to the radio field
2049        assert_eq!(color.widget_obj_nums.len(), 2);
2050        assert!(
2051            color.children.is_empty(),
2052            "widget /Kids should not become children"
2053        );
2054
2055        let country = &form.fields[3];
2056        match &country.kind {
2057            crate::FieldKind::Choice(c) => {
2058                assert!(c.combo);
2059                assert_eq!(c.options.len(), 2);
2060                assert_eq!(c.options[0].export, "US");
2061                assert_eq!(c.options[0].display, "United States");
2062            }
2063            _ => panic!("country should be a Choice"),
2064        }
2065
2066        let shipping = &form.fields[4];
2067        assert_eq!(shipping.name, "shipping");
2068        assert!(matches!(shipping.kind, crate::FieldKind::Container));
2069        assert_eq!(shipping.children.len(), 2);
2070        assert_eq!(shipping.children[0].name, "shipping.street");
2071        assert_eq!(shipping.children[1].name, "shipping.zip");
2072        assert_eq!(
2073            shipping.children[0].value,
2074            crate::FieldValue::Text("123 Main".to_string())
2075        );
2076    }
2077
2078    #[test]
2079    fn form_caches_across_calls() {
2080        let pdf = build_pdf_with_form();
2081        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2082        let a = doc.form().unwrap();
2083        let b = doc.form().unwrap();
2084        assert!(std::ptr::eq(a, b), "form() must be cached");
2085    }
2086
2087    #[test]
2088    fn form_absent_returns_none() {
2089        let pdf = build_minimal_pdf();
2090        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2091        assert!(doc.form().is_none());
2092    }
2093
2094    /// Build a one-page PDF declaring all five page boxes plus a
2095    /// non-default UserUnit and a /Rotate of 90.
2096    fn build_pdf_with_page_boxes() -> Vec<u8> {
2097        let mut pdf = Vec::new();
2098        pdf.extend(b"%PDF-1.4\n");
2099
2100        let mut offsets: Vec<usize> = Vec::new();
2101        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
2102            offsets.push(buf.len());
2103            buf.extend(body);
2104        };
2105
2106        push_obj(
2107            &mut pdf,
2108            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
2109        );
2110        push_obj(
2111            &mut pdf,
2112            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
2113        );
2114        // Page with all 5 boxes distinct, /Rotate 90, /UserUnit 1.5,
2115        // /Dur 5, /Trans presence, /AA presence.
2116        push_obj(
2117            &mut pdf,
2118            b"3 0 obj\n<< /Type /Page /Parent 2 0 R \
2119              /MediaBox [0 0 612 792] \
2120              /CropBox  [10 10 602 782] \
2121              /BleedBox [5 5 607 787] \
2122              /TrimBox  [20 20 592 772] \
2123              /ArtBox   [30 30 582 762] \
2124              /Rotate 90 /UserUnit 1.5 /Dur 5.0 \
2125              /Trans << /S /Wipe >> /AA << /O 5 0 R >> >>\nendobj\n",
2126        );
2127
2128        let xref_offset = pdf.len();
2129        pdf.extend(b"xref\n0 4\n");
2130        pdf.extend(b"0000000000 65535 f\r\n");
2131        for off in &offsets {
2132            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
2133        }
2134        pdf.extend(b"trailer\n<< /Size 4 /Root 1 0 R >>\n");
2135        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
2136
2137        pdf
2138    }
2139
2140    #[test]
2141    fn page_boxes_full_set() {
2142        let pdf = build_pdf_with_page_boxes();
2143        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2144        let pb = doc.page_boxes(0).unwrap();
2145        assert_eq!(pb.media_box, [0.0, 0.0, 612.0, 792.0]);
2146        assert_eq!(pb.crop_box, Some([10.0, 10.0, 602.0, 782.0]));
2147        assert_eq!(pb.bleed_box, Some([5.0, 5.0, 607.0, 787.0]));
2148        assert_eq!(pb.trim_box, Some([20.0, 20.0, 592.0, 772.0]));
2149        assert_eq!(pb.art_box, Some([30.0, 30.0, 582.0, 762.0]));
2150        assert_eq!(pb.rotate, 90);
2151        assert_eq!(pb.user_unit, 1.5);
2152        assert_eq!(pb.duration, Some(5.0));
2153        assert!(pb.has_transition);
2154        assert!(pb.has_additional_actions);
2155    }
2156
2157    #[test]
2158    fn page_boxes_minimal_defaults() {
2159        let pdf = build_minimal_pdf();
2160        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2161        let pb = doc.page_boxes(0).unwrap();
2162        assert_eq!(pb.media_box, [0.0, 0.0, 612.0, 792.0]);
2163        // No CropBox / BleedBox / TrimBox / ArtBox declared.
2164        assert!(pb.crop_box.is_none());
2165        assert!(pb.bleed_box.is_none());
2166        assert!(pb.trim_box.is_none());
2167        assert!(pb.art_box.is_none());
2168        assert_eq!(pb.rotate, 0);
2169        assert_eq!(pb.user_unit, 1.0);
2170        assert!(pb.duration.is_none());
2171        assert!(!pb.has_transition);
2172        assert!(!pb.has_additional_actions);
2173    }
2174
2175    #[test]
2176    fn page_boxes_out_of_range() {
2177        let pdf = build_minimal_pdf();
2178        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2179        assert!(doc.page_boxes(99).is_err());
2180    }
2181
2182    /// Build a PDF carrying one embedded file via the catalog's
2183    /// /Names /EmbeddedFiles name tree. The attached "data.csv" is
2184    /// stored uncompressed so we can round-trip its bytes through
2185    /// embedded_file_bytes.
2186    fn build_pdf_with_embedded_file() -> Vec<u8> {
2187        let mut pdf = Vec::new();
2188        pdf.extend(b"%PDF-1.4\n");
2189
2190        let mut offsets: Vec<usize> = Vec::new();
2191        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
2192            offsets.push(buf.len());
2193            buf.extend(body);
2194        };
2195
2196        // 1: Catalog → Names dict at obj 4
2197        push_obj(
2198            &mut pdf,
2199            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Names 4 0 R >>\nendobj\n",
2200        );
2201        // 2: Pages
2202        push_obj(
2203            &mut pdf,
2204            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
2205        );
2206        // 3: Page
2207        push_obj(
2208            &mut pdf,
2209            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
2210        );
2211        // 4: /Names dict pointing at /EmbeddedFiles tree at obj 5
2212        push_obj(&mut pdf, b"4 0 obj\n<< /EmbeddedFiles 5 0 R >>\nendobj\n");
2213        // 5: name-tree leaf, single entry "data.csv" → filespec at obj 6
2214        push_obj(
2215            &mut pdf,
2216            b"5 0 obj\n<< /Names [(data.csv) 6 0 R] >>\nendobj\n",
2217        );
2218        // 6: filespec dict
2219        push_obj(
2220            &mut pdf,
2221            b"6 0 obj\n<< /Type /Filespec /F (data.csv) /UF (data.csv) \
2222              /Desc (Sample CSV) /AFRelationship /Data \
2223              /EF << /F 7 0 R /UF 7 0 R >> >>\nendobj\n",
2224        );
2225        // 7: embedded-file stream — uncompressed payload "id,name\n1,a\n"
2226        // (12 bytes). /Length 12.
2227        let payload = b"id,name\n1,a\n";
2228        let stream_header = b"7 0 obj\n<< /Type /EmbeddedFile /Subtype /text#2Fcsv \
2229            /Length 12 /Params << /Size 12 >> >>\nstream\n";
2230        offsets.push(pdf.len());
2231        pdf.extend(stream_header);
2232        pdf.extend(payload);
2233        pdf.extend(b"\nendstream\nendobj\n");
2234
2235        let xref_offset = pdf.len();
2236        pdf.extend(b"xref\n0 8\n");
2237        pdf.extend(b"0000000000 65535 f\r\n");
2238        for off in &offsets {
2239            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
2240        }
2241        pdf.extend(b"trailer\n<< /Size 8 /Root 1 0 R >>\n");
2242        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
2243
2244        pdf
2245    }
2246
2247    #[test]
2248    fn embedded_files_basic() {
2249        let pdf = build_pdf_with_embedded_file();
2250        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2251        let map = doc.embedded_files();
2252        assert_eq!(map.len(), 1);
2253
2254        let ef = map.get("data.csv").expect("data.csv missing");
2255        assert_eq!(ef.name, "data.csv");
2256        assert_eq!(ef.filename.as_deref(), Some("data.csv"));
2257        assert_eq!(ef.unicode_filename.as_deref(), Some("data.csv"));
2258        assert_eq!(ef.description.as_deref(), Some("Sample CSV"));
2259        assert_eq!(ef.relationship, Some(crate::AfRelationship::Data));
2260        assert_eq!(ef.mime_type.as_deref(), Some("text/csv"));
2261        assert_eq!(ef.size, Some(12));
2262
2263        let bytes = doc.embedded_file_bytes("data.csv").unwrap();
2264        assert_eq!(&bytes[..], b"id,name\n1,a\n");
2265    }
2266
2267    #[test]
2268    fn embedded_files_caches_across_calls() {
2269        let pdf = build_pdf_with_embedded_file();
2270        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2271        let a = doc.embedded_files();
2272        let b = doc.embedded_files();
2273        assert!(std::ptr::eq(a, b), "embedded_files() must be cached");
2274    }
2275
2276    /// Build a PDF whose outline tree has a cycle: outline node 5
2277    /// references itself as its own /Next sibling.
2278    fn build_pdf_with_cyclic_outline() -> Vec<u8> {
2279        let mut pdf = Vec::new();
2280        pdf.extend(b"%PDF-1.4\n");
2281
2282        let mut offsets: Vec<usize> = Vec::new();
2283        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
2284            offsets.push(buf.len());
2285            buf.extend(body);
2286        };
2287
2288        push_obj(
2289            &mut pdf,
2290            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>\nendobj\n",
2291        );
2292        push_obj(
2293            &mut pdf,
2294            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
2295        );
2296        push_obj(
2297            &mut pdf,
2298            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
2299        );
2300        push_obj(
2301            &mut pdf,
2302            b"4 0 obj\n<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count 1 >>\nendobj\n",
2303        );
2304        // 5: cyclic — /Next points back at itself.
2305        push_obj(
2306            &mut pdf,
2307            b"5 0 obj\n<< /Title (Loop) /Parent 4 0 R /Next 5 0 R >>\nendobj\n",
2308        );
2309
2310        let xref_offset = pdf.len();
2311        pdf.extend(b"xref\n0 6\n");
2312        pdf.extend(b"0000000000 65535 f\r\n");
2313        for off in &offsets {
2314            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
2315        }
2316        pdf.extend(b"trailer\n<< /Size 6 /Root 1 0 R >>\n");
2317        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
2318
2319        pdf
2320    }
2321
2322    #[test]
2323    fn warning_emitted_for_outline_cycle() {
2324        let pdf = build_pdf_with_cyclic_outline();
2325        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2326        // Trigger outline parse.
2327        let outline = doc.outline();
2328        // The single Loop entry parses; the cycle stops further siblings.
2329        assert_eq!(outline.len(), 1);
2330        let warnings = doc.parse_warnings();
2331        assert!(
2332            warnings
2333                .iter()
2334                .any(|w| matches!(w.phase, crate::ParsePhase::Outline)
2335                    && w.severity == crate::Severity::Warning
2336                    && w.message.contains("cycle")),
2337            "expected outline cycle warning, got: {:?}",
2338            warnings.iter().collect::<Vec<_>>()
2339        );
2340    }
2341
2342    /// Build a PDF where the page's /Annots array references an
2343    /// annotation dict that has /Subtype but no /Rect.
2344    fn build_pdf_with_rectless_annot() -> Vec<u8> {
2345        let mut pdf = Vec::new();
2346        pdf.extend(b"%PDF-1.4\n");
2347
2348        let mut offsets: Vec<usize> = Vec::new();
2349        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
2350            offsets.push(buf.len());
2351            buf.extend(body);
2352        };
2353
2354        push_obj(
2355            &mut pdf,
2356            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
2357        );
2358        push_obj(
2359            &mut pdf,
2360            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
2361        );
2362        push_obj(
2363            &mut pdf,
2364            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
2365              /Annots [4 0 R] >>\nendobj\n",
2366        );
2367        // Annotation with /Subtype but missing /Rect.
2368        push_obj(
2369            &mut pdf,
2370            b"4 0 obj\n<< /Type /Annot /Subtype /Text /Contents (no rect) >>\nendobj\n",
2371        );
2372
2373        let xref_offset = pdf.len();
2374        pdf.extend(b"xref\n0 5\n");
2375        pdf.extend(b"0000000000 65535 f\r\n");
2376        for off in &offsets {
2377            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
2378        }
2379        pdf.extend(b"trailer\n<< /Size 5 /Root 1 0 R >>\n");
2380        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
2381
2382        pdf
2383    }
2384
2385    #[test]
2386    fn warning_emitted_for_rectless_annotation() {
2387        let pdf = build_pdf_with_rectless_annot();
2388        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2389        let annots = doc.page_annotations(0).unwrap();
2390        // The /Rect-less annotation is skipped.
2391        assert_eq!(annots.len(), 0);
2392        let warnings = doc.parse_warnings();
2393        assert!(
2394            warnings.iter().any(
2395                |w| matches!(w.phase, crate::ParsePhase::Annotations { page: 0 })
2396                    && w.message.contains("/Rect")
2397            ),
2398            "expected /Rect warning, got: {:?}",
2399            warnings.iter().collect::<Vec<_>>()
2400        );
2401    }
2402
2403    #[test]
2404    fn parse_warnings_empty_for_clean_document() {
2405        let pdf = build_minimal_pdf();
2406        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2407        // Touch every accessor; nothing should warn for a clean doc.
2408        let _ = doc.metadata();
2409        let _ = doc.viewer_preferences();
2410        let _ = doc.outline();
2411        let _ = doc.destinations();
2412        let _ = doc.page_annotations(0).unwrap();
2413        let _ = doc.form();
2414        let _ = doc.embedded_files();
2415        let _ = doc.page_boxes(0).unwrap();
2416        let warnings = doc.parse_warnings();
2417        assert_eq!(
2418            warnings.len(),
2419            0,
2420            "got: {:?}",
2421            warnings.iter().collect::<Vec<_>>()
2422        );
2423    }
2424
2425    #[test]
2426    fn embedded_files_empty_when_absent() {
2427        let pdf = build_minimal_pdf();
2428        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2429        assert!(doc.embedded_files().is_empty());
2430        assert!(doc.embedded_file_bytes("missing").is_err());
2431    }
2432
2433    #[test]
2434    fn form_widgets_appear_in_page_annotations() {
2435        let pdf = build_pdf_with_form();
2436        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2437        let form = doc.form().unwrap();
2438        let annots = doc.page_annotations(0).unwrap();
2439
2440        // Every widget obj_num declared by a terminal field should
2441        // resolve to a Widget annotation on the page. Use the existing
2442        // PageInfo.annots ordering: pages are matched by obj_num.
2443        let widget_annot_subtypes: Vec<_> = annots
2444            .iter()
2445            .filter(|a| a.kind == crate::AnnotationKind::Widget)
2446            .collect();
2447        assert!(
2448            !widget_annot_subtypes.is_empty(),
2449            "expected widget annotations on page"
2450        );
2451
2452        // The radio "color" field declares 2 widgets; assert both are
2453        // in the page's annotation set (we look up by inspecting the
2454        // page's annot ref obj_nums; PageInfo.annots is ordered, so
2455        // we just count).
2456        let color_field = form.fields.iter().find(|f| f.name == "color").unwrap();
2457        assert_eq!(color_field.widget_obj_nums.len(), 2);
2458    }
2459
2460    #[test]
2461    fn resolve_named_destination_returns_dest() {
2462        let pdf = build_pdf_with_legacy_dests();
2463        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2464        let d = doc.resolve_named_destination("Intro").unwrap();
2465        match d {
2466            crate::Destination::PageView { page, view } => {
2467                assert_eq!(page, Some(0));
2468                assert_eq!(view, crate::ViewSpec::Fit);
2469            }
2470            _ => panic!("expected PageView"),
2471        }
2472        assert!(doc.resolve_named_destination("MissingName").is_none());
2473    }
2474
2475    /// Build a PDF with five OCGs that together exercise every Phase 1
2476    /// metadata path:
2477    ///
2478    /// - Object 5: minimal OCG with a PDFDocEncoding `/Name`.
2479    /// - Object 6: OCG whose name is UTF-16BE with BOM, with an array
2480    ///   `/Intent` of two values, locked, and full `/Usage` sub-dict
2481    ///   covering View/Print/Export/Zoom/Language/User/PageElement/CreatorInfo.
2482    /// - Object 7: OCG with single-name array `/Intent`
2483    ///   (`[/Design]`) — should collapse to `LayerIntent::Design`.
2484    /// - Object 8: OCG with `/Intent /Custom` — `LayerIntent::Other`.
2485    /// - Object 9: OCG default-OFF (listed in `/D /OFF`) and
2486    ///   `/CreatorInfo` directly on the OCG dict.
2487    fn build_pdf_with_layers() -> Vec<u8> {
2488        let mut pdf = Vec::new();
2489        pdf.extend(b"%PDF-1.6\n");
2490
2491        let mut offsets: Vec<usize> = Vec::new();
2492        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
2493            offsets.push(buf.len());
2494            buf.extend(body);
2495        };
2496
2497        // 1: Catalog with /OCProperties
2498        push_obj(
2499            &mut pdf,
2500            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << \
2501              /OCGs [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] \
2502              /D << /Order [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] \
2503                    /OFF [9 0 R] /Locked [6 0 R] >> \
2504              >> >>\nendobj\n",
2505        );
2506        // 2: Pages
2507        push_obj(
2508            &mut pdf,
2509            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
2510        );
2511        // 3: Page
2512        push_obj(
2513            &mut pdf,
2514            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
2515        );
2516        // 4: (placeholder so OCG numbering matches the doc-comment)
2517        push_obj(&mut pdf, b"4 0 obj\nnull\nendobj\n");
2518
2519        // 5: simplest OCG — only /Type and /Name (PDFDocEncoding ASCII).
2520        push_obj(
2521            &mut pdf,
2522            b"5 0 obj\n<< /Type /OCG /Name (Background) >>\nendobj\n",
2523        );
2524
2525        // 6: OCG with UTF-16BE name (FE FF "T" "e" "s" "t") plus full
2526        // /Usage and array /Intent.
2527        let mut obj6 = Vec::new();
2528        obj6.extend(b"6 0 obj\n<< /Type /OCG ");
2529        obj6.extend(b"/Name <FEFF005400650073007400200394> ");
2530        // Array /Intent with two distinct names.
2531        obj6.extend(b"/Intent [/View /Design] ");
2532        // /Usage — every sub-dict.
2533        obj6.extend(b"/Usage << ");
2534        obj6.extend(b"/View << /ViewState /ON >> ");
2535        obj6.extend(b"/Print << /PrintState /OFF /Subtype /Watermark >> ");
2536        obj6.extend(b"/Export << /ExportState /ON >> ");
2537        obj6.extend(b"/Zoom << /min 0.5 /max 4.0 >> ");
2538        obj6.extend(b"/Language << /Lang (en-US) /Preferred /ON >> ");
2539        obj6.extend(b"/User << /Type /Ind /Name (alice) >> ");
2540        obj6.extend(b"/PageElement << /Subtype /HF >> ");
2541        obj6.extend(b"/CreatorInfo << /Creator (CADtool) /Subtype /Technical >> ");
2542        obj6.extend(b">> ");
2543        obj6.extend(b">>\nendobj\n");
2544        push_obj(&mut pdf, &obj6);
2545
2546        // 7: single-element array /Intent → should collapse to Design.
2547        push_obj(
2548            &mut pdf,
2549            b"7 0 obj\n<< /Type /OCG /Name (DesignLayer) /Intent [/Design] >>\nendobj\n",
2550        );
2551
2552        // 8: unknown intent name → LayerIntent::Other.
2553        push_obj(
2554            &mut pdf,
2555            b"8 0 obj\n<< /Type /OCG /Name (Custom) /Intent /Custom >>\nendobj\n",
2556        );
2557
2558        // 9: default-OFF, /CreatorInfo on the OCG itself, /User array.
2559        push_obj(
2560            &mut pdf,
2561            b"9 0 obj\n<< /Type /OCG /Name (HiddenLayer) \
2562              /CreatorInfo << /Creator (Inkscape) /Subtype /Artwork >> \
2563              /Usage << /User << /Type /Org /Name [(group-a) (group-b)] >> >> \
2564              >>\nendobj\n",
2565        );
2566
2567        let xref_offset = pdf.len();
2568        pdf.extend(b"xref\n0 10\n");
2569        pdf.extend(b"0000000000 65535 f\r\n");
2570        for off in &offsets {
2571            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
2572        }
2573        pdf.extend(b"trailer\n<< /Size 10 /Root 1 0 R >>\n");
2574        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
2575        pdf
2576    }
2577
2578    #[test]
2579    fn layers_basic_enumeration() {
2580        let pdf = build_pdf_with_layers();
2581        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2582        let layers = doc.layers();
2583        assert_eq!(layers.len(), 5, "expected 5 OCGs, got {}", layers.len());
2584
2585        // Default-OFF set: only object 9 is in /D /OFF.
2586        assert!(
2587            layers
2588                .iter()
2589                .find(|l| l.ocg_id == 5)
2590                .unwrap()
2591                .default_visible
2592        );
2593        assert!(
2594            layers
2595                .iter()
2596                .find(|l| l.ocg_id == 6)
2597                .unwrap()
2598                .default_visible
2599        );
2600        assert!(
2601            !layers
2602                .iter()
2603                .find(|l| l.ocg_id == 9)
2604                .unwrap()
2605                .default_visible
2606        );
2607
2608        // Locked set: only object 6 is in /D /Locked.
2609        assert!(layers.iter().find(|l| l.ocg_id == 6).unwrap().locked);
2610        assert!(!layers.iter().find(|l| l.ocg_id == 5).unwrap().locked);
2611        assert!(!layers.iter().find(|l| l.ocg_id == 9).unwrap().locked);
2612    }
2613
2614    #[test]
2615    fn layers_name_decoding() {
2616        let pdf = build_pdf_with_layers();
2617        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2618
2619        // Object 5: PDFDocEncoding ASCII → "Background".
2620        let bg = doc.layer(5).unwrap();
2621        assert_eq!(bg.name, "Background");
2622
2623        // Object 6: UTF-16BE BOM + "Test " + GREEK CAPITAL LETTER DELTA (U+0394).
2624        let utf16 = doc.layer(6).unwrap();
2625        assert_eq!(utf16.name, "Test \u{0394}");
2626    }
2627
2628    #[test]
2629    fn layers_intent_variants() {
2630        let pdf = build_pdf_with_layers();
2631        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2632
2633        // No /Intent → default View.
2634        assert_eq!(doc.layer(5).unwrap().intent, LayerIntent::View);
2635
2636        // Two-element array /Intent → Multiple.
2637        match &doc.layer(6).unwrap().intent {
2638            LayerIntent::Multiple(names) => {
2639                assert_eq!(names.len(), 2);
2640                assert_eq!(names[0], "View");
2641                assert_eq!(names[1], "Design");
2642            }
2643            other => panic!("expected Multiple, got {other:?}"),
2644        }
2645
2646        // Single-element array /Intent → collapses to Design.
2647        assert_eq!(doc.layer(7).unwrap().intent, LayerIntent::Design);
2648
2649        // Unknown name /Intent → Other.
2650        match &doc.layer(8).unwrap().intent {
2651            LayerIntent::Other(s) => assert_eq!(s, "Custom"),
2652            other => panic!("expected Other, got {other:?}"),
2653        }
2654    }
2655
2656    #[test]
2657    fn layers_full_usage_dict() {
2658        let pdf = build_pdf_with_layers();
2659        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2660        let l = doc.layer(6).unwrap();
2661
2662        // /View
2663        let view = l.usage.view.expect("view sub-dict");
2664        assert_eq!(view.state, UsageState::On);
2665
2666        // /Print with subtype
2667        let print = l.usage.print.as_ref().expect("print sub-dict");
2668        assert_eq!(print.state, UsageState::Off);
2669        assert_eq!(print.subtype.as_deref(), Some("Watermark"));
2670
2671        // /Export
2672        let export = l.usage.export.expect("export sub-dict");
2673        assert_eq!(export.state, UsageState::On);
2674
2675        // /Zoom
2676        let zoom = l.usage.zoom.expect("zoom sub-dict");
2677        assert_eq!(zoom.min, Some(0.5));
2678        assert_eq!(zoom.max, Some(4.0));
2679
2680        // /Language
2681        let lang = l.usage.language.as_ref().expect("language sub-dict");
2682        assert_eq!(lang.lang, "en-US");
2683        assert!(lang.preferred);
2684
2685        // /User (single string form)
2686        let user = l.usage.user.as_ref().expect("user sub-dict");
2687        assert_eq!(user.user_type.as_deref(), Some("Ind"));
2688        assert_eq!(user.names, vec!["alice".to_string()]);
2689
2690        // /PageElement
2691        assert_eq!(l.usage.page_element, Some(PageElementSubtype::HeaderFooter));
2692
2693        // /CreatorInfo nested under /Usage
2694        let ci = l.usage.creator_info.as_ref().expect("creator_info");
2695        assert_eq!(ci.creator, "CADtool");
2696        assert_eq!(ci.subtype.as_deref(), Some("Technical"));
2697    }
2698
2699    #[test]
2700    fn layers_creator_info_on_ocg() {
2701        let pdf = build_pdf_with_layers();
2702        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2703        let hidden = doc.layer(9).unwrap();
2704
2705        // /CreatorInfo on the OCG itself.
2706        let ci = hidden.creator_info.as_ref().expect("creator_info");
2707        assert_eq!(ci.creator, "Inkscape");
2708        assert_eq!(ci.subtype.as_deref(), Some("Artwork"));
2709
2710        // /User /Name as an array of strings.
2711        let user = hidden.usage.user.as_ref().expect("user sub-dict");
2712        assert_eq!(user.user_type.as_deref(), Some("Org"));
2713        assert_eq!(
2714            user.names,
2715            vec!["group-a".to_string(), "group-b".to_string()]
2716        );
2717    }
2718
2719    #[test]
2720    fn layers_empty_when_no_oc_properties() {
2721        let pdf = build_minimal_pdf();
2722        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2723        assert!(doc.layers().is_empty());
2724        assert!(doc.layer(42).is_none());
2725    }
2726
2727    #[test]
2728    fn layers_caches_across_calls() {
2729        let pdf = build_pdf_with_layers();
2730        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2731        let first = doc.layers().as_ptr();
2732        let second = doc.layers().as_ptr();
2733        assert_eq!(first, second, "layers() should return a cached slice");
2734    }
2735
2736    /// Build a PDF whose `/D` configuration exercises every Phase 2
2737    /// parsing path:
2738    ///
2739    /// - Five OCGs in `/OCGs` (objects 5..=9).
2740    /// - `/Order` mixes a flat layer ref, a string-labelled section,
2741    ///   a header-layer section, and a bare nested array.
2742    /// - `/BaseState /OFF` with explicit `/ON` overrides.
2743    /// - One `/AS` rule.
2744    /// - One `/RBGroups` group.
2745    /// - `/ListMode /VisiblePages`.
2746    /// - `/Configs` with one alternate configuration that has its
2747    ///   own `/Name`, `/Creator`, `/Intent /Design`, and a different
2748    ///   `/Order`.
2749    fn build_pdf_with_layer_hierarchy() -> Vec<u8> {
2750        let mut pdf = Vec::new();
2751        pdf.extend(b"%PDF-1.6\n");
2752
2753        let mut offsets: Vec<usize> = Vec::new();
2754        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
2755            offsets.push(buf.len());
2756            buf.extend(body);
2757        };
2758
2759        // 1: Catalog with a rich /OCProperties.
2760        let mut cat = Vec::new();
2761        cat.extend(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << ");
2762        cat.extend(b"/OCGs [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] ");
2763        cat.extend(b"/D << /Name (Default) /Creator (TestApp) ");
2764        cat.extend(b"/BaseState /OFF /ON [5 0 R 7 0 R] /OFF [9 0 R] ");
2765        cat.extend(b"/Locked [6 0 R] ");
2766        cat.extend(b"/Intent /View ");
2767        cat.extend(b"/ListMode /VisiblePages ");
2768        // /Order: flat ref, string-labelled section with two leaves,
2769        // header-layer section (8 leads its own subarray), bare nested
2770        // array (anonymous section).
2771        cat.extend(b"/Order [5 0 R (Backgrounds) [6 0 R 7 0 R] 8 0 R [9 0 R] [5 0 R]] ");
2772        cat.extend(b"/RBGroups [[6 0 R 7 0 R]] ");
2773        cat.extend(b"/AS [<< /Event /Print /Category [/Print] /OCGs [9 0 R] >>] ");
2774        cat.extend(b">> ");
2775        cat.extend(b"/Configs [<< /Name (Alternate) /Creator (Other) ");
2776        cat.extend(b"/BaseState /ON /OFF [5 0 R] /Intent /Design ");
2777        cat.extend(b"/Order [6 0 R 7 0 R] >>] ");
2778        cat.extend(b">> >>\nendobj\n");
2779        push_obj(&mut pdf, &cat);
2780
2781        push_obj(
2782            &mut pdf,
2783            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
2784        );
2785        push_obj(
2786            &mut pdf,
2787            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
2788        );
2789        // 4: filler so OCG numbering matches doc-comment.
2790        push_obj(&mut pdf, b"4 0 obj\nnull\nendobj\n");
2791        // 5..=9: minimal OCGs.
2792        for (n, name) in (5u32..=9).zip(["L5", "L6", "L7", "L8", "L9"]) {
2793            let body = format!("{n} 0 obj\n<< /Type /OCG /Name ({name}) >>\nendobj\n");
2794            push_obj(&mut pdf, body.as_bytes());
2795        }
2796
2797        let xref_offset = pdf.len();
2798        pdf.extend(b"xref\n0 10\n");
2799        pdf.extend(b"0000000000 65535 f\r\n");
2800        for off in &offsets {
2801            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
2802        }
2803        pdf.extend(b"trailer\n<< /Size 10 /Root 1 0 R >>\n");
2804        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
2805        pdf
2806    }
2807
2808    #[test]
2809    fn configurations_default_and_alternate() {
2810        let pdf = build_pdf_with_layer_hierarchy();
2811        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2812        let configs = doc.configurations();
2813        assert_eq!(configs.len(), 2, "default + one alternate");
2814
2815        let d = doc.default_configuration().unwrap();
2816        assert_eq!(d.index, 0);
2817        assert_eq!(d.name.as_deref(), Some("Default"));
2818        assert_eq!(d.creator.as_deref(), Some("TestApp"));
2819        assert_eq!(d.base_state, BaseState::Off);
2820        assert_eq!(d.on, vec![5, 7]);
2821        assert_eq!(d.off, vec![9]);
2822        assert_eq!(d.locked, vec![6]);
2823        assert_eq!(d.list_mode, ListMode::VisiblePages);
2824        assert_eq!(d.intent, LayerIntent::View);
2825
2826        let alt = doc.configuration(1).unwrap();
2827        assert_eq!(alt.index, 1);
2828        assert_eq!(alt.name.as_deref(), Some("Alternate"));
2829        assert_eq!(alt.creator.as_deref(), Some("Other"));
2830        assert_eq!(alt.base_state, BaseState::On);
2831        assert_eq!(alt.off, vec![5]);
2832        assert_eq!(alt.intent, LayerIntent::Design);
2833    }
2834
2835    #[test]
2836    fn order_mixes_flat_labelled_header_and_anonymous_sections() {
2837        let pdf = build_pdf_with_layer_hierarchy();
2838        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2839        let tree = doc.layer_tree();
2840
2841        // /Order: 5 0 R, "Backgrounds" [6,7], 8 0 R [9], [5]
2842        // Phase 2 parser:
2843        //   nodes[0] = Layer(5)
2844        //   nodes[1] = Section{label="Backgrounds", header_layer=None, children=[Layer(6),Layer(7)]}
2845        //   nodes[2] = Section{header_layer=Some(8), children=[Layer(9)]}
2846        //   nodes[3] = Section{header_layer=None, label=None, children=[Layer(5)]}
2847        assert_eq!(tree.nodes.len(), 4, "expected 4 top-level nodes");
2848
2849        match &tree.nodes[0] {
2850            LayerTreeNode::Layer(id) => assert_eq!(*id, 5),
2851            other => panic!("nodes[0]: expected Layer(5), got {other:?}"),
2852        }
2853        match &tree.nodes[1] {
2854            LayerTreeNode::Section {
2855                label,
2856                header_layer,
2857                children,
2858            } => {
2859                assert_eq!(label.as_deref(), Some("Backgrounds"));
2860                assert!(header_layer.is_none());
2861                assert_eq!(children.len(), 2);
2862                if let LayerTreeNode::Layer(id) = &children[0] {
2863                    assert_eq!(*id, 6);
2864                } else {
2865                    panic!("children[0] not a Layer");
2866                }
2867                if let LayerTreeNode::Layer(id) = &children[1] {
2868                    assert_eq!(*id, 7);
2869                } else {
2870                    panic!("children[1] not a Layer");
2871                }
2872            }
2873            other => panic!("nodes[1]: expected labelled Section, got {other:?}"),
2874        }
2875        match &tree.nodes[2] {
2876            LayerTreeNode::Section {
2877                label,
2878                header_layer,
2879                children,
2880            } => {
2881                assert!(label.is_none());
2882                assert_eq!(*header_layer, Some(8));
2883                assert_eq!(children.len(), 1);
2884                if let LayerTreeNode::Layer(id) = &children[0] {
2885                    assert_eq!(*id, 9);
2886                } else {
2887                    panic!("children[0] not a Layer");
2888                }
2889            }
2890            other => panic!("nodes[2]: expected header-layer Section, got {other:?}"),
2891        }
2892        match &tree.nodes[3] {
2893            LayerTreeNode::Section {
2894                label,
2895                header_layer,
2896                children,
2897            } => {
2898                assert!(label.is_none());
2899                assert!(header_layer.is_none());
2900                assert_eq!(children.len(), 1);
2901                if let LayerTreeNode::Layer(id) = &children[0] {
2902                    assert_eq!(*id, 5);
2903                } else {
2904                    panic!("children[0] not a Layer");
2905                }
2906            }
2907            other => panic!("nodes[3]: expected anonymous Section, got {other:?}"),
2908        }
2909    }
2910
2911    #[test]
2912    fn auto_state_rules_parsed() {
2913        let pdf = build_pdf_with_layer_hierarchy();
2914        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2915        let d = doc.default_configuration().unwrap();
2916        assert_eq!(d.auto_state.len(), 1);
2917        let rule = &d.auto_state[0];
2918        assert_eq!(rule.event, AutoStateEvent::Print);
2919        assert_eq!(rule.categories, vec!["Print".to_string()]);
2920        assert_eq!(rule.ocgs, vec![9]);
2921    }
2922
2923    #[test]
2924    fn rb_groups_parsed() {
2925        let pdf = build_pdf_with_layer_hierarchy();
2926        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2927        let d = doc.default_configuration().unwrap();
2928        assert_eq!(d.rb_groups, vec![vec![6, 7]]);
2929    }
2930
2931    #[test]
2932    fn layer_tree_alternate_config_differs() {
2933        let pdf = build_pdf_with_layer_hierarchy();
2934        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2935        let alt = doc.configuration(1).unwrap();
2936        // Alternate /Order is a flat list of two layers.
2937        assert_eq!(alt.order.nodes.len(), 2);
2938        assert!(matches!(alt.order.nodes[0], LayerTreeNode::Layer(6)));
2939        assert!(matches!(alt.order.nodes[1], LayerTreeNode::Layer(7)));
2940    }
2941
2942    #[test]
2943    fn configurations_empty_when_no_oc_properties() {
2944        let pdf = build_minimal_pdf();
2945        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2946        assert!(doc.configurations().is_empty());
2947        assert!(doc.default_configuration().is_none());
2948        assert!(doc.configuration(0).is_none());
2949        assert!(doc.layer_tree().nodes.is_empty());
2950    }
2951
2952    #[test]
2953    fn configurations_caches_across_calls() {
2954        let pdf = build_pdf_with_layer_hierarchy();
2955        let doc = PdfDocument::from_bytes(&pdf).unwrap();
2956        let first = doc.configurations().as_ptr();
2957        let second = doc.configurations().as_ptr();
2958        assert_eq!(first, second);
2959    }
2960
2961    #[test]
2962    fn order_with_dangling_string_emits_warning() {
2963        // Build a tiny PDF whose /Order has a string with no following
2964        // array. The parser should drop the string and record a warning.
2965        let mut pdf = Vec::new();
2966        pdf.extend(b"%PDF-1.6\n");
2967        let mut offsets: Vec<usize> = Vec::new();
2968        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
2969            offsets.push(buf.len());
2970            buf.extend(body);
2971        };
2972
2973        push_obj(
2974            &mut pdf,
2975            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << \
2976              /OCGs [4 0 R] /D << /Order [(Orphan) 4 0 R] >> >> >>\nendobj\n",
2977        );
2978        push_obj(
2979            &mut pdf,
2980            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
2981        );
2982        push_obj(
2983            &mut pdf,
2984            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
2985        );
2986        push_obj(
2987            &mut pdf,
2988            b"4 0 obj\n<< /Type /OCG /Name (Solo) >>\nendobj\n",
2989        );
2990        let xref_offset = pdf.len();
2991        pdf.extend(b"xref\n0 5\n");
2992        pdf.extend(b"0000000000 65535 f\r\n");
2993        for off in &offsets {
2994            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
2995        }
2996        pdf.extend(b"trailer\n<< /Size 5 /Root 1 0 R >>\n");
2997        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
2998
2999        let doc = PdfDocument::from_bytes(&pdf).unwrap();
3000        let tree = doc.layer_tree();
3001        // Orphan string dropped; remaining ref becomes a Layer leaf.
3002        assert_eq!(tree.nodes.len(), 1);
3003        assert!(matches!(tree.nodes[0], LayerTreeNode::Layer(4)));
3004
3005        let warnings = doc.parse_warnings();
3006        assert!(
3007            warnings
3008                .iter()
3009                .any(|w| matches!(w.phase, ParsePhase::Layers) && w.message.contains("Orphan")),
3010            "expected a Layers warning about the orphan string, got {warnings:?}"
3011        );
3012    }
3013
3014    /// Build a PDF whose page draws two filled rectangles, one wrapped
3015    /// in an `/OC BDC` block tied to OCG object 5 (default ON).
3016    /// Layer 6 references `MissingLayer` (no resource entry) so the
3017    /// content gets emitted unwrapped — provides a baseline rectangle
3018    /// that's always visible.
3019    fn build_pdf_with_layered_content() -> Vec<u8> {
3020        let mut pdf = Vec::new();
3021        pdf.extend(b"%PDF-1.6\n");
3022        let mut offsets: Vec<usize> = Vec::new();
3023        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
3024            offsets.push(buf.len());
3025            buf.extend(body);
3026        };
3027
3028        // 1: Catalog with /OCProperties listing one OCG.
3029        push_obj(
3030            &mut pdf,
3031            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << \
3032              /OCGs [5 0 R] /D << /Order [5 0 R] >> >> >>\nendobj\n",
3033        );
3034        // 2: Pages.
3035        push_obj(
3036            &mut pdf,
3037            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
3038        );
3039        // 3: Page referencing the OCG via /Resources /Properties.
3040        push_obj(
3041            &mut pdf,
3042            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
3043              /Contents 4 0 R /Resources << /Properties << /OC1 5 0 R >> >> >>\nendobj\n",
3044        );
3045        // 4: Content stream — baseline red rect, then layer-wrapped blue rect.
3046        let stream = b"q 1 0 0 rg 0 0 50 50 re f Q\n\
3047                       /OC /OC1 BDC q 0 0 1 rg 50 50 50 50 re f Q EMC";
3048        let stream_obj = format!(
3049            "4 0 obj\n<< /Length {} >>\nstream\n{}\nendstream\nendobj\n",
3050            stream.len(),
3051            std::str::from_utf8(stream).unwrap()
3052        );
3053        push_obj(&mut pdf, stream_obj.as_bytes());
3054        // 5: OCG.
3055        push_obj(
3056            &mut pdf,
3057            b"5 0 obj\n<< /Type /OCG /Name (BlueLayer) >>\nendobj\n",
3058        );
3059
3060        let xref_offset = pdf.len();
3061        pdf.extend(b"xref\n0 6\n");
3062        pdf.extend(b"0000000000 65535 f\r\n");
3063        for off in &offsets {
3064            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
3065        }
3066        pdf.extend(b"trailer\n<< /Size 6 /Root 1 0 R >>\n");
3067        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
3068        pdf
3069    }
3070
3071    /// Sample the centre of the layered region (pixel 75,25 in a 100x100
3072    /// page) so we can detect whether the layer's content rendered.
3073    fn sample_pixel(rgba: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] {
3074        let i = (y as usize * w as usize + x as usize) * 4;
3075        [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]
3076    }
3077
3078    #[cfg(feature = "render")]
3079    #[test]
3080    fn render_default_layer_set_matches_implicit_render() {
3081        let pdf = build_pdf_with_layered_content();
3082        let doc = PdfDocument::from_bytes(&pdf).unwrap();
3083
3084        let (rgba_default, w, h) = doc.render_page_to_rgba(0, 72.0).unwrap();
3085        let (rgba_with_set, w2, h2) = doc
3086            .render_page_to_rgba_with_layers(0, 72.0, &LayerSet::new())
3087            .unwrap();
3088
3089        assert_eq!(w, w2);
3090        assert_eq!(h, h2);
3091        assert_eq!(
3092            rgba_default, rgba_with_set,
3093            "empty LayerSet must render byte-identical to plain render_page_to_rgba"
3094        );
3095    }
3096
3097    #[cfg(feature = "render")]
3098    #[test]
3099    fn render_layer_off_hides_layer_content() {
3100        let pdf = build_pdf_with_layered_content();
3101        let doc = PdfDocument::from_bytes(&pdf).unwrap();
3102
3103        // Default render — blue rect at (75, 25) should be visible.
3104        // Page is in PDF Y-up coords; (50,50)-(100,100) maps to top-right
3105        // in device space (origin at top-left). So sample top-right.
3106        let (rgba_on, w, _h) = doc.render_page_to_rgba(0, 72.0).unwrap();
3107        let on_pixel = sample_pixel(&rgba_on, w, 75, 25);
3108        assert!(
3109            on_pixel[2] > 200 && on_pixel[0] < 50,
3110            "expected blue layer pixel, got rgba={:?}",
3111            on_pixel
3112        );
3113
3114        // Toggle layer 5 OFF.
3115        let mut layers = layers::layer_set_from_document(&doc);
3116        layers.set(5, false);
3117
3118        let (rgba_off, _w, _h) = doc
3119            .render_page_to_rgba_with_layers(0, 72.0, &layers)
3120            .unwrap();
3121        let off_pixel = sample_pixel(&rgba_off, w, 75, 25);
3122        assert!(
3123            off_pixel[0] >= 250 && off_pixel[1] >= 250 && off_pixel[2] >= 250,
3124            "expected layer-off pixel to be background white, got rgba={:?}",
3125            off_pixel
3126        );
3127
3128        // Baseline red rect still rendered (independent of layer).
3129        let baseline = sample_pixel(&rgba_off, w, 25, 75);
3130        assert!(
3131            baseline[0] > 200 && baseline[1] < 50 && baseline[2] < 50,
3132            "baseline red rect should still render, got rgba={:?}",
3133            baseline
3134        );
3135    }
3136
3137    #[test]
3138    fn layer_set_from_document_populates_defaults() {
3139        let pdf = build_pdf_with_layers();
3140        let doc = PdfDocument::from_bytes(&pdf).unwrap();
3141        let set = layers::layer_set_from_document(&doc);
3142
3143        // Object 9 was default-OFF; rest are ON.
3144        assert_eq!(set.get(5), Some(true));
3145        assert_eq!(set.get(6), Some(true));
3146        assert_eq!(set.get(9), Some(false));
3147    }
3148
3149    /// Build a PDF with one OCMD wrapping a content block.
3150    ///
3151    /// `policy` is one of `b"AllOn"` / `b"AnyOn"` / `b"AllOff"` /
3152    /// `b"AnyOff"`. The OCMD references OCGs 5 and 6 from /Properties
3153    /// /OC1. /OCProperties /D /OFF lists the OCGs supplied in `off`,
3154    /// so the OCMD's static evaluation matches the per-leaf defaults.
3155    fn build_pdf_with_ocmd(policy: &[u8], off: &[u32]) -> Vec<u8> {
3156        let mut pdf = Vec::new();
3157        pdf.extend(b"%PDF-1.6\n");
3158        let mut offsets: Vec<usize> = Vec::new();
3159        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
3160            offsets.push(buf.len());
3161            buf.extend(body);
3162        };
3163
3164        let mut off_arr = String::from("[");
3165        for id in off {
3166            off_arr.push_str(&format!("{id} 0 R "));
3167        }
3168        off_arr.push(']');
3169
3170        let cat = format!(
3171            "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << \
3172              /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] /OFF {off_arr} >> >> >>\nendobj\n"
3173        );
3174        push_obj(&mut pdf, cat.as_bytes());
3175        push_obj(
3176            &mut pdf,
3177            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
3178        );
3179        // Page references the OCMD via /Properties /OC1.
3180        push_obj(
3181            &mut pdf,
3182            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
3183              /Contents 4 0 R /Resources << /Properties << /OC1 7 0 R >> >> >>\nendobj\n",
3184        );
3185        // Content: red baseline + blue OCMD-wrapped rect.
3186        let stream = b"q 1 0 0 rg 0 0 50 50 re f Q\n\
3187                       /OC /OC1 BDC q 0 0 1 rg 50 50 50 50 re f Q EMC";
3188        let stream_obj = format!(
3189            "4 0 obj\n<< /Length {} >>\nstream\n{}\nendstream\nendobj\n",
3190            stream.len(),
3191            std::str::from_utf8(stream).unwrap()
3192        );
3193        push_obj(&mut pdf, stream_obj.as_bytes());
3194        push_obj(
3195            &mut pdf,
3196            b"5 0 obj\n<< /Type /OCG /Name (LayerA) >>\nendobj\n",
3197        );
3198        push_obj(
3199            &mut pdf,
3200            b"6 0 obj\n<< /Type /OCG /Name (LayerB) >>\nendobj\n",
3201        );
3202        // OCMD over LayerA + LayerB with the requested policy.
3203        let ocmd = format!(
3204            "7 0 obj\n<< /Type /OCMD /OCGs [5 0 R 6 0 R] /P /{} >>\nendobj\n",
3205            std::str::from_utf8(policy).unwrap()
3206        );
3207        push_obj(&mut pdf, ocmd.as_bytes());
3208
3209        let xref_offset = pdf.len();
3210        pdf.extend(b"xref\n0 8\n");
3211        pdf.extend(b"0000000000 65535 f\r\n");
3212        for off_v in &offsets {
3213            pdf.extend(format!("{:010} 00000 n\r\n", off_v).as_bytes());
3214        }
3215        pdf.extend(b"trailer\n<< /Size 8 /Root 1 0 R >>\n");
3216        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
3217        pdf
3218    }
3219
3220    fn ocmd_visibility(pdf: &[u8]) -> OcgVisibility {
3221        let doc = PdfDocument::from_bytes(pdf).unwrap();
3222        let dl = doc.render_page(0, 72.0).unwrap();
3223        for elem in dl.elements() {
3224            if let stet_graphics::display_list::DisplayElement::OcgGroup { visibility, .. } = elem {
3225                return visibility.clone();
3226            }
3227        }
3228        panic!("expected an OcgGroup in display list")
3229    }
3230
3231    #[test]
3232    fn ocmd_emits_membership_with_policy() {
3233        let v = ocmd_visibility(&build_pdf_with_ocmd(b"AllOn", &[]));
3234        match v {
3235            OcgVisibility::Membership {
3236                ocg_ids,
3237                policy,
3238                default_visible,
3239            } => {
3240                assert_eq!(ocg_ids, vec![5, 6]);
3241                assert_eq!(policy, MembershipPolicy::AllOn);
3242                // Both leaves on by default → AllOn → visible.
3243                assert!(default_visible);
3244            }
3245            other => panic!("expected Membership, got {other:?}"),
3246        }
3247
3248        // AnyOff with both default ON → policy fails → invisible.
3249        let v = ocmd_visibility(&build_pdf_with_ocmd(b"AnyOff", &[]));
3250        match v {
3251            OcgVisibility::Membership {
3252                policy,
3253                default_visible,
3254                ..
3255            } => {
3256                assert_eq!(policy, MembershipPolicy::AnyOff);
3257                assert!(!default_visible);
3258            }
3259            other => panic!("expected Membership, got {other:?}"),
3260        }
3261
3262        // AllOff with both default OFF → invisible would hold for AllOff → visible.
3263        let v = ocmd_visibility(&build_pdf_with_ocmd(b"AllOff", &[5, 6]));
3264        match v {
3265            OcgVisibility::Membership {
3266                policy,
3267                default_visible,
3268                ..
3269            } => {
3270                assert_eq!(policy, MembershipPolicy::AllOff);
3271                assert!(default_visible);
3272            }
3273            other => panic!("expected Membership, got {other:?}"),
3274        }
3275    }
3276
3277    #[test]
3278    fn ocmd_membership_truth_table_via_layer_set() {
3279        // /AllOn over [5, 6] with both default ON.
3280        let pdf = build_pdf_with_ocmd(b"AllOn", &[]);
3281        let v = ocmd_visibility(&pdf);
3282
3283        for a in [false, true] {
3284            for b in [false, true] {
3285                let mut s = LayerSet::new();
3286                s.set(5, a);
3287                s.set(6, b);
3288                let expected = a && b;
3289                assert_eq!(
3290                    s.evaluate(&v),
3291                    expected,
3292                    "AllOn(5={a}, 6={b}) expected {expected}"
3293                );
3294            }
3295        }
3296    }
3297
3298    /// Build a PDF with an OCMD using a `/VE` expression
3299    /// `[/And [layer_5] [/Or [layer_6] [/Not [layer_7]]]]`.
3300    fn build_pdf_with_ve_expression() -> Vec<u8> {
3301        let mut pdf = Vec::new();
3302        pdf.extend(b"%PDF-1.6\n");
3303        let mut offsets: Vec<usize> = Vec::new();
3304        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
3305            offsets.push(buf.len());
3306            buf.extend(body);
3307        };
3308
3309        push_obj(
3310            &mut pdf,
3311            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << \
3312              /OCGs [5 0 R 6 0 R 7 0 R] /D << /Order [5 0 R 6 0 R 7 0 R] >> >> >>\nendobj\n",
3313        );
3314        push_obj(
3315            &mut pdf,
3316            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
3317        );
3318        push_obj(
3319            &mut pdf,
3320            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
3321              /Contents 4 0 R /Resources << /Properties << /OC1 8 0 R >> >> >>\nendobj\n",
3322        );
3323        let stream = b"/OC /OC1 BDC q 0 0 1 rg 0 0 100 100 re f Q EMC";
3324        let stream_obj = format!(
3325            "4 0 obj\n<< /Length {} >>\nstream\n{}\nendstream\nendobj\n",
3326            stream.len(),
3327            std::str::from_utf8(stream).unwrap()
3328        );
3329        push_obj(&mut pdf, stream_obj.as_bytes());
3330        push_obj(&mut pdf, b"5 0 obj\n<< /Type /OCG /Name (A) >>\nendobj\n");
3331        push_obj(&mut pdf, b"6 0 obj\n<< /Type /OCG /Name (B) >>\nendobj\n");
3332        push_obj(&mut pdf, b"7 0 obj\n<< /Type /OCG /Name (C) >>\nendobj\n");
3333        // OCMD with /VE = [/And [/Layer 5] [/Or [/Layer 6] [/Not [/Layer 7]]]]
3334        // PDF /VE leaves are bare OCG refs (e.g. `5 0 R`), not nested
3335        // arrays — only operators wrap their operands in arrays.
3336        push_obj(
3337            &mut pdf,
3338            b"8 0 obj\n<< /Type /OCMD /VE [/And 5 0 R [/Or 6 0 R [/Not 7 0 R]]] >>\nendobj\n",
3339        );
3340
3341        let xref_offset = pdf.len();
3342        pdf.extend(b"xref\n0 9\n");
3343        pdf.extend(b"0000000000 65535 f\r\n");
3344        for off in &offsets {
3345            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
3346        }
3347        pdf.extend(b"trailer\n<< /Size 9 /Root 1 0 R >>\n");
3348        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
3349        pdf
3350    }
3351
3352    #[test]
3353    fn ve_expression_parsed_into_visibility_expr() {
3354        let pdf = build_pdf_with_ve_expression();
3355        let v = ocmd_visibility(&pdf);
3356        match &v {
3357            OcgVisibility::Expression { expr, .. } => match expr {
3358                VisibilityExpr::And(args) => {
3359                    assert_eq!(args.len(), 2);
3360                    assert!(matches!(args[0], VisibilityExpr::Layer(5)));
3361                    match &args[1] {
3362                        VisibilityExpr::Or(or_args) => {
3363                            assert_eq!(or_args.len(), 2);
3364                            assert!(matches!(or_args[0], VisibilityExpr::Layer(6)));
3365                            match &or_args[1] {
3366                                VisibilityExpr::Not(inner) => {
3367                                    assert!(matches!(**inner, VisibilityExpr::Layer(7)));
3368                                }
3369                                other => panic!("expected Not, got {other:?}"),
3370                            }
3371                        }
3372                        other => panic!("expected Or, got {other:?}"),
3373                    }
3374                }
3375                other => panic!("expected And, got {other:?}"),
3376            },
3377            other => panic!("expected Expression, got {other:?}"),
3378        }
3379
3380        // Truth table over (a, b, c) for a && (b || !c).
3381        for a in [false, true] {
3382            for b in [false, true] {
3383                for c in [false, true] {
3384                    let mut s = LayerSet::new();
3385                    s.set(5, a);
3386                    s.set(6, b);
3387                    s.set(7, c);
3388                    let expected = a && (b || !c);
3389                    assert_eq!(
3390                        s.evaluate(&v),
3391                        expected,
3392                        "(a={a}, b={b}, c={c}) expected {expected}"
3393                    );
3394                }
3395            }
3396        }
3397    }
3398
3399    #[test]
3400    fn malformed_ve_falls_back_to_membership() {
3401        // /VE with an unknown leading operator name.
3402        let mut pdf = Vec::new();
3403        pdf.extend(b"%PDF-1.6\n");
3404        let mut offsets: Vec<usize> = Vec::new();
3405        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
3406            offsets.push(buf.len());
3407            buf.extend(body);
3408        };
3409        push_obj(
3410            &mut pdf,
3411            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << \
3412              /OCGs [5 0 R] /D << /Order [5 0 R] >> >> >>\nendobj\n",
3413        );
3414        push_obj(
3415            &mut pdf,
3416            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
3417        );
3418        push_obj(
3419            &mut pdf,
3420            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
3421              /Contents 4 0 R /Resources << /Properties << /OC1 6 0 R >> >> >>\nendobj\n",
3422        );
3423        let stream = b"/OC /OC1 BDC q 1 0 0 rg 0 0 100 100 re f Q EMC";
3424        let stream_obj = format!(
3425            "4 0 obj\n<< /Length {} >>\nstream\n{}\nendstream\nendobj\n",
3426            stream.len(),
3427            std::str::from_utf8(stream).unwrap()
3428        );
3429        push_obj(&mut pdf, stream_obj.as_bytes());
3430        push_obj(&mut pdf, b"5 0 obj\n<< /Type /OCG /Name (X) >>\nendobj\n");
3431        // /VE with /Not arity 2 — invalid → falls back to /OCGs membership.
3432        push_obj(
3433            &mut pdf,
3434            b"6 0 obj\n<< /Type /OCMD /VE [/Not 5 0 R 5 0 R] /OCGs [5 0 R] /P /AnyOn >>\nendobj\n",
3435        );
3436        let xref_offset = pdf.len();
3437        pdf.extend(b"xref\n0 7\n");
3438        pdf.extend(b"0000000000 65535 f\r\n");
3439        for off in &offsets {
3440            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
3441        }
3442        pdf.extend(b"trailer\n<< /Size 7 /Root 1 0 R >>\n");
3443        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
3444
3445        let v = ocmd_visibility(&pdf);
3446        match v {
3447            OcgVisibility::Membership {
3448                ocg_ids, policy, ..
3449            } => {
3450                assert_eq!(ocg_ids, vec![5]);
3451                assert_eq!(policy, MembershipPolicy::AnyOn);
3452            }
3453            other => panic!("expected fallback to Membership, got {other:?}"),
3454        }
3455    }
3456
3457    #[test]
3458    fn layer_set_from_configuration_applies_base_state() {
3459        let pdf = build_pdf_with_layer_hierarchy();
3460        let doc = PdfDocument::from_bytes(&pdf).unwrap();
3461
3462        // Default config: BaseState=Off, ON=[5,7], OFF=[9].
3463        let d = layers::layer_set_from_configuration(&doc, 0).unwrap();
3464        assert_eq!(d.get(5), Some(true));
3465        assert_eq!(d.get(6), Some(false));
3466        assert_eq!(d.get(7), Some(true));
3467        assert_eq!(d.get(8), Some(false));
3468        assert_eq!(d.get(9), Some(false));
3469
3470        // Alternate config: BaseState=On, OFF=[5].
3471        let alt = layers::layer_set_from_configuration(&doc, 1).unwrap();
3472        assert_eq!(alt.get(5), Some(false));
3473        assert_eq!(alt.get(6), Some(true));
3474        assert_eq!(alt.get(7), Some(true));
3475
3476        // Out-of-range index returns None.
3477        assert!(layers::layer_set_from_configuration(&doc, 99).is_none());
3478    }
3479
3480    /// Build a PDF with three OCGs and a pair of `/AS` rules:
3481    ///
3482    /// - Layer 5 ("Watermark") has `/Usage /Print /PrintState /OFF`
3483    ///   and an `/AS` rule that turns it OFF on `/Print`.
3484    /// - Layer 6 ("ScreenOnly") has `/Usage /View /ViewState /ON` and
3485    ///   an `/AS` rule that turns it OFF on `/Print`.
3486    /// - Layer 7 ("Hint") has `/Usage /Export /ExportState /OFF` but
3487    ///   **no** matching `/AS` rule — should stay at default
3488    ///   regardless of intent.
3489    fn build_pdf_with_auto_state_rules() -> Vec<u8> {
3490        let mut pdf = Vec::new();
3491        pdf.extend(b"%PDF-1.6\n");
3492        let mut offsets: Vec<usize> = Vec::new();
3493        let mut push_obj = |buf: &mut Vec<u8>, body: &[u8]| {
3494            offsets.push(buf.len());
3495            buf.extend(body);
3496        };
3497
3498        let mut cat = Vec::new();
3499        cat.extend(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << ");
3500        cat.extend(b"/OCGs [5 0 R 6 0 R 7 0 R] /D << /Order [5 0 R 6 0 R 7 0 R] ");
3501        cat.extend(b"/AS [");
3502        // Rule 1: on Print, consult /Print category for Watermark + ScreenOnly.
3503        cat.extend(b"<< /Event /Print /Category [/Print] /OCGs [5 0 R 6 0 R] >> ");
3504        // Rule 2: on Export, consult /Export category for Watermark only.
3505        cat.extend(b"<< /Event /Export /Category [/Export] /OCGs [5 0 R] >>");
3506        cat.extend(b"] ");
3507        cat.extend(b">> >>\nendobj\n");
3508        push_obj(&mut pdf, &cat);
3509        push_obj(
3510            &mut pdf,
3511            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
3512        );
3513        push_obj(
3514            &mut pdf,
3515            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>\nendobj\n",
3516        );
3517        // 4: filler.
3518        push_obj(&mut pdf, b"4 0 obj\nnull\nendobj\n");
3519        // 5: Watermark — ON by default; /AS Print → OFF.
3520        push_obj(
3521            &mut pdf,
3522            b"5 0 obj\n<< /Type /OCG /Name (Watermark) \
3523              /Usage << /Print << /PrintState /OFF /Subtype /Watermark >> \
3524                       /Export << /ExportState /OFF >> \
3525                    >> >>\nendobj\n",
3526        );
3527        // 6: ScreenOnly — Usage /View ON, /Print would turn OFF.
3528        push_obj(
3529            &mut pdf,
3530            b"6 0 obj\n<< /Type /OCG /Name (ScreenOnly) \
3531              /Usage << /View << /ViewState /ON >> \
3532                       /Print << /PrintState /OFF >> \
3533                    >> >>\nendobj\n",
3534        );
3535        // 7: Hint — has /Export usage hint but no matching /AS rule.
3536        push_obj(
3537            &mut pdf,
3538            b"7 0 obj\n<< /Type /OCG /Name (Hint) \
3539              /Usage << /Export << /ExportState /OFF >> >> >>\nendobj\n",
3540        );
3541
3542        let xref_offset = pdf.len();
3543        pdf.extend(b"xref\n0 8\n");
3544        pdf.extend(b"0000000000 65535 f\r\n");
3545        for off in &offsets {
3546            pdf.extend(format!("{:010} 00000 n\r\n", off).as_bytes());
3547        }
3548        pdf.extend(b"trailer\n<< /Size 8 /Root 1 0 R >>\n");
3549        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
3550        pdf
3551    }
3552
3553    #[test]
3554    fn layer_set_for_view_keeps_defaults() {
3555        let pdf = build_pdf_with_auto_state_rules();
3556        let doc = PdfDocument::from_bytes(&pdf).unwrap();
3557        let set = doc.layer_set_for(RenderIntent::View);
3558
3559        // No /AS rule fires for View; every layer stays at its default.
3560        assert_eq!(set.get(5), Some(true));
3561        assert_eq!(set.get(6), Some(true));
3562        assert_eq!(set.get(7), Some(true));
3563    }
3564
3565    #[test]
3566    fn layer_set_for_print_applies_off_rules() {
3567        let pdf = build_pdf_with_auto_state_rules();
3568        let doc = PdfDocument::from_bytes(&pdf).unwrap();
3569        let set = doc.layer_set_for(RenderIntent::Print);
3570
3571        // Rule 1 fired: Watermark and ScreenOnly turn OFF for print.
3572        assert_eq!(set.get(5), Some(false));
3573        assert_eq!(set.get(6), Some(false));
3574        // Hint has /Usage hint but no /AS rule → still default ON.
3575        assert_eq!(set.get(7), Some(true));
3576    }
3577
3578    #[test]
3579    fn layer_set_for_export_only_touches_listed_ocgs() {
3580        let pdf = build_pdf_with_auto_state_rules();
3581        let doc = PdfDocument::from_bytes(&pdf).unwrap();
3582        let set = doc.layer_set_for(RenderIntent::Export);
3583
3584        // Rule 2 fired: Watermark off for export.
3585        assert_eq!(set.get(5), Some(false));
3586        // ScreenOnly is not listed in any /Export rule → default.
3587        assert_eq!(set.get(6), Some(true));
3588        // Hint's /Usage is informational only without an /AS rule.
3589        assert_eq!(set.get(7), Some(true));
3590    }
3591
3592    #[test]
3593    fn layer_set_for_with_no_oc_properties_returns_empty() {
3594        let pdf = build_minimal_pdf();
3595        let doc = PdfDocument::from_bytes(&pdf).unwrap();
3596        let set = doc.layer_set_for(RenderIntent::Print);
3597        assert!(set.is_empty());
3598    }
3599
3600    /// Build a minimal valid PDF for testing.
3601    fn build_minimal_pdf() -> Vec<u8> {
3602        let mut pdf = Vec::new();
3603        pdf.extend(b"%PDF-1.4\n");
3604
3605        // Object 1: Catalog
3606        let obj1_offset = pdf.len();
3607        pdf.extend(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
3608
3609        // Object 2: Pages
3610        let obj2_offset = pdf.len();
3611        pdf.extend(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
3612
3613        // Object 3: Page
3614        let obj3_offset = pdf.len();
3615        pdf.extend(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n");
3616
3617        // Xref
3618        let xref_offset = pdf.len();
3619        pdf.extend(b"xref\n0 4\n");
3620        pdf.extend(b"0000000000 65535 f\r\n");
3621        pdf.extend(format!("{:010} 00000 n\r\n", obj1_offset).as_bytes());
3622        pdf.extend(format!("{:010} 00000 n\r\n", obj2_offset).as_bytes());
3623        pdf.extend(format!("{:010} 00000 n\r\n", obj3_offset).as_bytes());
3624        pdf.extend(b"trailer\n<< /Size 4 /Root 1 0 R >>\n");
3625        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
3626
3627        pdf
3628    }
3629}