Skip to main content

oxideav_pdf/reader/
document.rs

1//! Top-level reader — bytes → resolved [`Document`] / [`Scene`].
2//!
3//! Glues [`crate::reader::xref`] (locate + parse cross-reference
4//! table) and [`crate::reader::parse`] (decode an indirect object at
5//! a given byte offset) into a one-shot pipeline:
6//!
7//! 1. [`load_xref`] — locate `startxref`, parse the xref table,
8//!    keep the trailer dict.
9//! 2. [`fetch_object`] — given an [`ObjectId`], seek to the byte
10//!    offset, decode the indirect object's body (recursively
11//!    resolving references on demand).
12//! 3. [`read_pdf_to_scene`] — top-level entry point: bytes →
13//!    [`oxideav_scene::Scene`] in pages mode. Walks the catalog →
14//!    pages tree → per-page Contents → content-stream parser, and
15//!    extracts /Info → [`Metadata`].
16//!
17//! Round 3 supports PDF 1.4 with a simple xref + uncompressed object
18//! streams. FlateDecode-compressed Contents streams **are** decoded
19//! here — the writer FlateDecode-compresses image XObjects + may
20//! later compress content streams; supporting it now keeps the
21//! reader symmetric with the writer's output. Object streams (PDF
22//! 1.5+) and encryption are deferred to round 4+.
23
24use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
25
26use oxideav_core::vector::{
27    FillRule, Group, Node, Paint, Path, PathCommand, PathNode, Point, Rgba, Transform2D,
28    VectorFrame,
29};
30use oxideav_core::TimeBase;
31use oxideav_scene::{Metadata, Page, Scene};
32
33use crate::decrypt::{open_with_password, StandardHandler};
34use crate::error::PdfError;
35use crate::objects::{Dict, Object, ObjectId, Stream};
36use crate::pubsec::{
37    open_with_certificate, open_with_certificate_and_trust_store, PubSecCredential, TrustStore,
38};
39use crate::reader::content::{
40    parse_content_stream_full_with_tiling, parse_content_stream_full_with_type3, TilingPattern,
41    Type3Font,
42};
43use crate::reader::parse::Parser;
44use crate::reader::xref::{parse_xref, XrefEntry, XrefTable};
45
46/// A read-time view of the PDF document — owns the byte slice plus a
47/// resolved cross-reference table and a small object cache. Indirect
48/// objects are decoded lazily via [`Self::resolve`].
49///
50/// When the file's trailer carries an `/Encrypt` entry, the reader
51/// holds a [`StandardHandler`] derived from the supplied password.
52/// Every [`Self::resolve`] call decrypts string and stream payloads
53/// against that handler before caching them. PDFs without encryption
54/// leave `crypt = None` and the decrypt path is a no-op.
55pub struct DocumentReader<'a> {
56    input: &'a [u8],
57    xref: XrefTable,
58    cache: HashMap<ObjectId, Object>,
59    crypt: Option<StandardHandler>,
60    /// §7.5.7 compressed-object resolver memo. Each entry holds the
61    /// FlateDecode-decompressed body of one ObjStm container plus the
62    /// parsed `(obj_num, byte_offset_inside_payload)` header pairs.
63    /// Without this cache, resolving N compressed objects whose xref
64    /// type-2 entries all point at one container costs O(N²) — every
65    /// `resolve(compressed)` call re-decompresses the whole stream
66    /// and re-parses the `n` header pairs from scratch. Keyed by the
67    /// container object number (not [`ObjectId`] — the generation of
68    /// an ObjStm is always 0 per §7.5.7).
69    objstm_cache: HashMap<u32, ObjStmDecoded>,
70}
71
72/// Cached decoded ObjStm: the Flate-expanded payload + the parsed
73/// header table mapping each slot index to `(obj_num, body_offset)`
74/// inside the payload. `first` is `pairs[0].1 + the base` — but the
75/// resolver only needs to add the per-slot body_offset to the
76/// absolute payload base, which we precompute here so the hot path
77/// is a single `HashMap` lookup + `Parser::new(&payload[abs..])`.
78struct ObjStmDecoded {
79    payload: Vec<u8>,
80    /// Absolute byte offset from `payload[0]` for slot `i`'s body.
81    /// `(obj_num, abs_offset_into_payload)`.
82    slots: Vec<(u32, usize)>,
83}
84
85impl<'a> DocumentReader<'a> {
86    /// Parse the cross-reference table + trailer for `input`. Equivalent
87    /// to [`Self::open_with_password`] with the empty password — works
88    /// for unencrypted PDFs and for PDFs whose user password is empty.
89    pub fn open(input: &'a [u8]) -> Result<Self, PdfError> {
90        Self::open_with_password(input, b"")
91    }
92
93    /// Parse the cross-reference table + trailer for `input`. If the
94    /// trailer carries `/Encrypt`, derive a decryption handler from the
95    /// supplied password (tested first as the user password, then as
96    /// the owner password per ISO 32000-1 §7.6.3.1).
97    ///
98    /// Returns [`PdfError::Other`] when the file is encrypted but the
99    /// password fails to authenticate — the typical "wrong password"
100    /// error a PDF viewer surfaces.
101    pub fn open_with_password(input: &'a [u8], password: &[u8]) -> Result<Self, PdfError> {
102        let xref = parse_xref(input)?;
103        let crypt = build_crypt(&xref, input, password)?;
104        Ok(Self {
105            input,
106            xref,
107            cache: HashMap::new(),
108            objstm_cache: HashMap::new(),
109            crypt,
110        })
111    }
112
113    /// Parse the cross-reference table + trailer and unlock a
114    /// public-key-protected PDF using `credential`. Round-10
115    /// implementation; see [`crate::pubsec`] for the supported
116    /// SubFilters and crypt methods. Returns `PdfError::Other` when
117    /// the PDF is encrypted but the supplied certificate doesn't
118    /// match any recipient slot in any envelope of `/Recipients`.
119    pub fn open_with_certificate(
120        input: &'a [u8],
121        credential: &PubSecCredential,
122    ) -> Result<Self, PdfError> {
123        let xref = parse_xref(input)?;
124        let crypt = build_crypt_pubsec(&xref, input, credential, None)?;
125        Ok(Self {
126            input,
127            xref,
128            cache: HashMap::new(),
129            objstm_cache: HashMap::new(),
130            crypt,
131        })
132    }
133
134    /// Round-17: same as [`Self::open_with_certificate`] but consults a
135    /// [`TrustStore`] when a KARI envelope identifies the originator by
136    /// `IssuerAndSerial` or `SubjectKeyIdentifier` (RFC 5652 §6.2.2)
137    /// instead of carrying its public point in-band.
138    pub fn open_with_certificate_and_trust_store(
139        input: &'a [u8],
140        credential: &PubSecCredential,
141        trust_store: &TrustStore,
142    ) -> Result<Self, PdfError> {
143        let xref = parse_xref(input)?;
144        let crypt = build_crypt_pubsec(&xref, input, credential, Some(trust_store))?;
145        Ok(Self {
146            input,
147            xref,
148            cache: HashMap::new(),
149            objstm_cache: HashMap::new(),
150            crypt,
151        })
152    }
153
154    /// The trailer dict (carries `/Root`, optional `/Info`, etc.).
155    pub fn xref(&self) -> &XrefTable {
156        &self.xref
157    }
158
159    /// `true` when the underlying PDF carried an `/Encrypt` entry that
160    /// the supplied password successfully authenticated against.
161    pub fn is_encrypted(&self) -> bool {
162        self.crypt.is_some()
163    }
164
165    /// Round-21: enumerate every `/Sig` form-field signature dictionary
166    /// embedded in this PDF. See [`crate::reader::sig::signatures`] for
167    /// the full contract — this is a thin convenience wrapper.
168    ///
169    /// ```rust,ignore
170    /// use oxideav_pdf::reader::DocumentReader;
171    /// use oxideav_pdf::pubsec::verify::{verify_signature, AttachedContent};
172    ///
173    /// let mut r = DocumentReader::open(&pdf)?;
174    /// for sig in r.signatures()? {
175    ///     if !sig.is_cms_detached() { continue; }
176    ///     let signed = sig.signed_message(&pdf)?;
177    ///     let sd = sig.signed_data.as_ref().unwrap();
178    ///     // ... resolve certs from sd.certs[] ...
179    ///     let ok = verify_signature(&sd.signer_infos[0], &certs,
180    ///         AttachedContent::External(&signed))?;
181    /// }
182    /// # Ok::<(), oxideav_pdf::PdfError>(())
183    /// ```
184    pub fn signatures(&mut self) -> Result<Vec<crate::reader::sig::PdfSignature>, PdfError> {
185        crate::reader::sig::signatures(self)
186    }
187
188    /// Round-34: surface only the document time-stamp signatures (ISO
189    /// 32000-1 §12.8.5 — `/Type /DocTimeStamp` or `/SubFilter
190    /// /ETSI.RFC3161`).
191    pub fn doc_timestamps(&mut self) -> Result<Vec<crate::reader::sig::PdfDocTimestamp>, PdfError> {
192        crate::reader::sig::doc_timestamps(self)
193    }
194
195    /// Round-19: surface the document-level XMP `/Metadata` packet
196    /// per ISO 32000-1 §14.3.2 + Adobe XMP Spec 2012. Returns
197    /// `Ok(None)` when the catalog has no `/Metadata` entry; otherwise
198    /// resolves the referenced stream and returns its decoded payload
199    /// (the raw XMP RDF/XML bytes — caller is expected to do their
200    /// own XML / RDF parse if they need structured access).
201    ///
202    /// Symmetric to [`crate::write_pdf_from_scene_with_xmp`].
203    /// Round-26: walk every page's `/Annots` array and surface each
204    /// annotation as a [`crate::reader::annotation::PdfAnnotation`]
205    /// (ISO 32000-1 §12.5).
206    ///
207    /// Subsumes [`Self::signatures`] (those land as `Other { subtype:
208    /// "Widget" }` plus `/FT /Sig` widget hosting) at a higher level —
209    /// callers that just want the structured `/Sig` slot should keep
210    /// using `signatures()`; callers that want every annotation across
211    /// every page (Text, FreeText, Stamp, Highlight, Square, Link,
212    /// Widget, …) want `annotations()`.
213    pub fn annotations(
214        &mut self,
215    ) -> Result<Vec<crate::reader::annotation::PdfAnnotation>, PdfError> {
216        crate::reader::annotation::annotations(self)
217    }
218
219    /// Round-36: enumerate every action attached to the document (ISO
220    /// 32000-1 §12.6). Walks the catalog `/OpenAction` + `/AA`,
221    /// per-page `/AA`, per-annotation `/A` + `/AA`, per-form-field
222    /// `/A` + `/AA`, and the `/Names /JavaScript` name tree, surfacing
223    /// each as a [`crate::reader::actions::PdfAction`] with the
224    /// trigger location, the typed [`crate::reader::actions::ActionKind`]
225    /// payload, and the `/Next` chain depth.
226    pub fn actions(&mut self) -> Result<Vec<crate::reader::actions::PdfAction>, PdfError> {
227        crate::reader::actions::actions(self)
228    }
229
230    /// Round-95: surface the catalog's `/OCProperties` Optional Content
231    /// configuration (ISO 32000-1 §8.11 + §7.7.2 Table 28). Returns
232    /// `Ok(None)` when the document has no optional content (the
233    /// common case); returns `Ok(Some(_))` carrying every OCG, the
234    /// default configuration dict, any alternate configurations, and
235    /// the resolved on/off state per group after applying the default
236    /// configuration's `BaseState` / `ON` / `OFF` per §8.11.4.5.
237    pub fn optional_content(
238        &mut self,
239    ) -> Result<Option<crate::reader::ocg::OptionalContent>, PdfError> {
240        crate::reader::ocg::optional_content(self)
241    }
242
243    /// Round-27: parse the Linearization Parameter Dictionary at the
244    /// head of the file (ISO 32000-1 §F.2 + Annex F.3). Returns
245    /// `Ok(None)` for non-linearized files (the common case);
246    /// returns `Ok(Some(_))` with parsed `/L /H /O /E /N /T` for
247    /// "Fast Web View" PDFs.
248    ///
249    /// Independent of the rest of the open path — the lin-dict
250    /// is parsed from the raw bytes, NOT from the resolved xref.
251    /// A reader can poll for linearization status without paying
252    /// the xref-walk cost.
253    pub fn linearization(
254        &self,
255    ) -> Result<Option<crate::reader::linearize::LinearizationParams>, PdfError> {
256        crate::reader::linearize::LinearizationParams::parse(self.input)
257    }
258
259    /// Round-27: walk Catalog → Pages → Page and collect every
260    /// integrity divergence per ISO 32000-1 §7.7.2 + §7.7.3. The
261    /// returned [`crate::reader::hierarchy::HierarchyReport`] is
262    /// permissive — it never aborts the walk, so callers can decide
263    /// per-issue what to do with warnings vs. errors.
264    pub fn verify_hierarchy(
265        &mut self,
266    ) -> Result<crate::reader::hierarchy::HierarchyReport, PdfError> {
267        crate::reader::hierarchy::verify_hierarchy(self)
268    }
269
270    /// Round-27: surface the structural PDF/A catalog signals
271    /// (`/MarkInfo`, `/StructTreeRoot`, `/Lang`, `/OutputIntents`,
272    /// `/Metadata`) independent of the XMP packet's claim.
273    ///
274    /// Pair with [`Self::xmp_packet`] + [`crate::reader::pdfa::PdfAConformance::from_signals_and_xmp`]
275    /// to cross-verify a `pdfaid:part` declaration against the
276    /// structural prerequisites ISO 19005-x requires.
277    pub fn pdfa_signals(&mut self) -> Result<crate::reader::pdfa::PdfACatalogSignals, PdfError> {
278        crate::reader::pdfa::pdfa_signals(self)
279    }
280
281    /// Round-27: combined PDF/A conformance picture — the XMP
282    /// packet's `pdfaid:part` / `pdfaid:conformance` claim cross-
283    /// verified against the catalog's structural signals
284    /// (`/MarkInfo /Marked`, `/StructTreeRoot`, `/OutputIntents`).
285    ///
286    /// Returns a [`crate::reader::pdfa::PdfAConformance`] whose
287    /// `claim_inconsistent` is `true` when the document declares
288    /// PDF/A in XMP but lacks one or more structural prerequisites.
289    pub fn pdfa_conformance(&mut self) -> Result<crate::reader::pdfa::PdfAConformance, PdfError> {
290        let signals = self.pdfa_signals()?;
291        let xmp = self.xmp_packet()?;
292        Ok(crate::reader::pdfa::PdfAConformance::from_signals_and_xmp(
293            &signals,
294            xmp.as_ref(),
295        ))
296    }
297
298    /// Round-26: surface the document-level XMP `/Metadata` packet as
299    /// a structured [`crate::reader::xmp::XmpPacket`] — the most-used
300    /// Dublin Core / XMP Basic / PDF / PDF/A identification fields,
301    /// pre-decoded from the raw bytes [`Self::xmp_metadata`] returns.
302    ///
303    /// Returns `Ok(None)` when the catalog has no `/Metadata` entry.
304    pub fn xmp_packet(&mut self) -> Result<Option<crate::reader::xmp::XmpPacket>, PdfError> {
305        Ok(self
306            .xmp_metadata()?
307            .as_deref()
308            .map(crate::reader::xmp::XmpPacket::parse))
309    }
310
311    pub fn xmp_metadata(&mut self) -> Result<Option<Vec<u8>>, PdfError> {
312        let root_id = self.xref.root()?;
313        let catalog = self.resolve(root_id)?;
314        let Object::Dict(catalog) = catalog else {
315            return Err(PdfError::other(format!(
316                "PDF reader: /Root must be a dictionary (got {catalog:?})"
317            )));
318        };
319        let metadata_obj = catalog
320            .entries()
321            .iter()
322            .find(|(k, _)| k == "Metadata")
323            .map(|(_, v)| v.clone());
324        let Some(metadata_obj) = metadata_obj else {
325            return Ok(None);
326        };
327        // /Metadata is conventionally an indirect reference (§14.3.2
328        // gives the catalog entry as `metadata stream`). Accept both
329        // direct-stream and reference-to-stream shapes.
330        let stream_obj = match metadata_obj {
331            Object::Reference(id) => self.resolve(id)?,
332            other => other,
333        };
334        let Object::Stream(s) = stream_obj else {
335            return Err(PdfError::other(format!(
336                "PDF reader: catalog /Metadata must resolve to a Stream (got {stream_obj:?})"
337            )));
338        };
339        Ok(Some(decode_stream(&s)?))
340    }
341
342    /// Decode the indirect object at `id`. Cached on first hit so a
343    /// second `resolve(id)` is O(1). When the file is encrypted, the
344    /// per-object decryption is applied here so callers above this
345    /// layer see plaintext only.
346    ///
347    /// Compressed objects (xref entry type 2 — PDF 1.5+ object
348    /// streams, ISO 32000-1 §7.5.7) are resolved by fetching their
349    /// containing object stream, slicing the matching body out of the
350    /// concatenated payload, and re-parsing it with the standard
351    /// object parser.
352    pub fn resolve(&mut self, id: ObjectId) -> Result<Object, PdfError> {
353        if let Some(o) = self.cache.get(&id) {
354            return Ok(o.clone());
355        }
356        // Compressed entries take a different path — they live inside
357        // an object stream (`/Type /ObjStm`) rather than at a byte
358        // offset. The container itself is encrypted (when the file is
359        // encrypted); the per-stored-object payload is **not**
360        // re-encrypted (§7.6.1, "object streams are encrypted as a
361        // unit").
362        if let Some(XrefEntry::Compressed {
363            obj_stream_id,
364            index_within_stream,
365        }) = self.xref.entries.get(&id.number).copied()
366        {
367            let body = self.resolve_compressed(id, obj_stream_id, index_within_stream)?;
368            self.cache.insert(id, body.clone());
369            return Ok(body);
370        }
371        let off = self
372            .xref
373            .offset_of(id)
374            .ok_or_else(|| PdfError::other(format!("PDF reader: object {id:?} not in xref")))?;
375        let mut p = Parser::new(self.input);
376        p.lexer_mut().seek(off as usize);
377        // ISO 32000-1 §7.3.10 Example 3: a stream's `/Length` may be
378        // an indirect reference, deferring the size until after the
379        // body for one-pass writers. Resolve the reference against
380        // the xref table — the target must be an in-use integer
381        // object at a known byte offset (Compressed targets are
382        // currently rejected; see docs-gap note in the resolver).
383        let input = self.input;
384        let xref_snapshot = &self.xref;
385        let id_being_parsed = id;
386        let mut resolver = move |length_ref_id: ObjectId| -> Result<i64, PdfError> {
387            resolve_indirect_length(input, xref_snapshot, length_ref_id, id_being_parsed)
388        };
389        let (parsed_id, mut body) = p.parse_indirect_with_length_resolver(&mut resolver)?;
390        if parsed_id != id {
391            return Err(PdfError::other(format!(
392                "PDF reader: xref points to wrong object — wanted {id:?}, got {parsed_id:?}"
393            )));
394        }
395        if let Some(crypt) = &self.crypt {
396            decrypt_object_in_place(&mut body, id, crypt)?;
397        }
398        self.cache.insert(id, body.clone());
399        Ok(body)
400    }
401
402    /// Resolve a compressed object — fetch + decode the containing
403    /// object stream (PDF 1.5+ `/Type /ObjStm`), slice the body whose
404    /// header matches `wanted.number`, and parse it with the standard
405    /// object parser.
406    ///
407    /// The decoded payload + header slot table are memoised in
408    /// [`Self::objstm_cache`] so the second-and-subsequent compressed
409    /// object resolved against the same container skip Flate
410    /// decompression + header re-parse. Without the cache the cost of
411    /// resolving the M compressed objects packed into one ObjStm
412    /// container is O(M²) (every call decompresses the full payload
413    /// and re-parses every header pair); with it the cost is
414    /// O(M) for the first call + O(1) per subsequent slot lookup.
415    fn resolve_compressed(
416        &mut self,
417        wanted: ObjectId,
418        obj_stream_num: u32,
419        index_within_stream: u32,
420    ) -> Result<Object, PdfError> {
421        // Fast path: container already decoded.
422        if let Some(decoded) = self.objstm_cache.get(&obj_stream_num) {
423            return Self::slot_from_decoded(decoded, wanted, index_within_stream);
424        }
425        let decoded = self.decode_objstm_container(wanted, obj_stream_num)?;
426        let body = Self::slot_from_decoded(&decoded, wanted, index_within_stream)?;
427        self.objstm_cache.insert(obj_stream_num, decoded);
428        Ok(body)
429    }
430
431    /// Fetch the container ObjStm object, validate its dict, Flate-
432    /// decompress the body, and parse the §7.5.7 header table into a
433    /// flat `[(obj_num, abs_payload_offset); N]` slice. Cached by
434    /// [`Self::resolve_compressed`].
435    fn decode_objstm_container(
436        &mut self,
437        wanted: ObjectId,
438        obj_stream_num: u32,
439    ) -> Result<ObjStmDecoded, PdfError> {
440        let container_id = ObjectId::new(obj_stream_num);
441        // §7.5.7: "An object stream shall not contain other object
442        // streams. Furthermore, the cross-reference entries for
443        // compressed objects shall not themselves use type 2 to point
444        // back at the containing object stream." A hostile xref that
445        // marks the container as itself compressed (or as compressed
446        // inside another container that is in turn compressed inside
447        // it) would otherwise loop `resolve` → `decode_objstm_container`
448        // → `resolve` until the stack overflows. Reject any Type-2 entry
449        // for the container before re-entering `resolve`. Caught from
450        // a fuzz finding (parse target stack-overflow on a crafted
451        // hybrid-reference file whose XRefStm declared object 1 as
452        // compressed inside container 1).
453        if let Some(XrefEntry::Compressed { .. }) = self.xref.entries.get(&container_id.number) {
454            return Err(PdfError::other(format!(
455                "PDF reader: ObjStm container {container_id:?} (for compressed object \
456                 {wanted:?}) is itself declared as a Type-2 compressed entry in the xref \
457                 — forbidden by ISO 32000-1 §7.5.7"
458            )));
459        }
460        let container = self.resolve(container_id)?;
461        let Object::Stream(s) = container else {
462            return Err(PdfError::other(format!(
463                "PDF reader: object {wanted:?} expected its container {container_id:?} to be a Stream"
464            )));
465        };
466        // /Type must be /ObjStm.
467        let dict = &s.dict;
468        let lookup = |k: &str| {
469            dict.entries()
470                .iter()
471                .find(|(kk, _)| kk == k)
472                .map(|(_, v)| v.clone())
473        };
474        if !matches!(lookup("Type"), Some(Object::Name(ref n)) if n == "ObjStm") {
475            return Err(PdfError::other(format!(
476                "PDF reader: container {container_id:?} is not /Type /ObjStm"
477            )));
478        }
479        let n = match lookup("N") {
480            Some(Object::Integer(v)) if v >= 0 => v as u32,
481            other => {
482                return Err(PdfError::other(format!(
483                    "PDF reader: ObjStm /N must be a non-negative integer (got {other:?})"
484                )))
485            }
486        };
487        let first = match lookup("First") {
488            Some(Object::Integer(v)) if v >= 0 => v as usize,
489            other => {
490                return Err(PdfError::other(format!(
491                    "PDF reader: ObjStm /First must be a non-negative integer (got {other:?})"
492                )))
493            }
494        };
495        let payload = decode_stream(&s)?;
496        if first > payload.len() {
497            return Err(PdfError::other(format!(
498                "PDF reader: ObjStm /First {first} exceeds payload length {}",
499                payload.len()
500            )));
501        }
502        let header_bytes = &payload[..first];
503        // Header is `obj_num_1 off_1 obj_num_2 off_2 ...` with
504        // whitespace separators per §7.5.7. Re-use the standard
505        // parser's integer machinery rather than re-implementing it.
506        let mut hp = Parser::new(header_bytes);
507        let mut slots: Vec<(u32, usize)> = Vec::with_capacity(n as usize);
508        for i in 0..n {
509            let on = hp.parse_object()?.ok_or_else(|| {
510                PdfError::other(format!(
511                    "PDF reader: ObjStm header truncated at pair {i} (obj_num)"
512                ))
513            })?;
514            let off = hp.parse_object()?.ok_or_else(|| {
515                PdfError::other(format!(
516                    "PDF reader: ObjStm header truncated at pair {i} (offset)"
517                ))
518            })?;
519            let (Object::Integer(num), Object::Integer(o)) = (on, off) else {
520                return Err(PdfError::other(format!(
521                    "PDF reader: ObjStm header pair {i} must be two integers"
522                )));
523            };
524            if num < 1 || o < 0 {
525                return Err(PdfError::other(format!(
526                    "PDF reader: ObjStm header pair {i} out of range ({num}, {o})"
527                )));
528            }
529            let abs_off = first
530                .checked_add(o as usize)
531                .ok_or_else(|| PdfError::other("PDF reader: ObjStm offset overflow"))?;
532            if abs_off > payload.len() {
533                return Err(PdfError::other(format!(
534                    "PDF reader: ObjStm body offset {abs_off} past payload length {}",
535                    payload.len()
536                )));
537            }
538            slots.push((num as u32, abs_off));
539        }
540        Ok(ObjStmDecoded { payload, slots })
541    }
542
543    /// Per-slot extraction against a cached [`ObjStmDecoded`].
544    /// Validates `index_within_stream` against the header table,
545    /// confirms the declared object number matches what the xref
546    /// promised, then parses one object out of the payload starting
547    /// at the precomputed absolute offset.
548    fn slot_from_decoded(
549        decoded: &ObjStmDecoded,
550        wanted: ObjectId,
551        index_within_stream: u32,
552    ) -> Result<Object, PdfError> {
553        let n = decoded.slots.len() as u32;
554        if index_within_stream >= n {
555            return Err(PdfError::other(format!(
556                "PDF reader: ObjStm index {index_within_stream} out of range (N={n})"
557            )));
558        }
559        let (header_num, abs_off) = decoded.slots[index_within_stream as usize];
560        if header_num != wanted.number {
561            return Err(PdfError::other(format!(
562                "PDF reader: ObjStm slot {index_within_stream} declares object {header_num},\
563                 but xref expected {}",
564                wanted.number
565            )));
566        }
567        // Compressed objects in an ObjStm cannot themselves be
568        // streams (§7.5.7), and have no `n gen obj` wrapper — we
569        // parse a single object starting at `abs_off`.
570        let mut bp = Parser::new(&decoded.payload[abs_off..]);
571        let body = bp
572            .parse_object()?
573            .ok_or_else(|| PdfError::other("PDF reader: ObjStm body parse returned EOF"))?;
574        Ok(body)
575    }
576
577    /// If `obj` is `Object::Reference`, follow it (recursively) until
578    /// a non-reference value resolves. Returns the deref'd value.
579    pub fn deref(&mut self, obj: Object) -> Result<Object, PdfError> {
580        let mut cur = obj;
581        let mut hops = 0;
582        while let Object::Reference(id) = cur {
583            cur = self.resolve(id)?;
584            hops += 1;
585            if hops > 16 {
586                return Err(PdfError::other(
587                    "PDF reader: indirect-reference chain too deep (>16 hops)",
588                ));
589            }
590        }
591        Ok(cur)
592    }
593}
594
595/// Round-91: resolve a stream's indirect `/Length` (ISO 32000-1
596/// §7.3.10 Example 3 — `<< /Length 8 0 R >> stream … endstream`).
597///
598/// The length-carrying object lives at a byte offset given by the
599/// xref table. Spec-conforming PDFs put a small `n 0 obj N endobj`
600/// integer there, so we re-enter the resolver-less parse path
601/// (deliberately — the length object is never itself a stream).
602///
603/// `containing_stream` is the object currently being read; passed in
604/// only to make the error message useful when an indirect reference
605/// is malformed (otherwise the caller has no breadcrumb back to the
606/// stream that triggered the lookup).
607///
608/// Cycle protection: an indirect reference whose target is itself an
609/// indirect-length stream pointing back at us is not a meaningful PDF
610/// shape — the length-carrying object is required to be a direct
611/// integer per §7.3.8.2 Table 5, so any chain longer than one hop is
612/// already malformed. We reject deeper chains with a clear message.
613fn resolve_indirect_length(
614    input: &[u8],
615    xref: &XrefTable,
616    length_ref: ObjectId,
617    containing_stream: ObjectId,
618) -> Result<i64, PdfError> {
619    if length_ref == containing_stream {
620        return Err(PdfError::other(format!(
621            "PDF reader: stream {containing_stream:?} /Length refers to itself"
622        )));
623    }
624    // Compressed entries (PDF 1.5+ object-stream-resident integers)
625    // would require fetching the container ObjStm first, which itself
626    // needs an xref walk. The mainstream encoders we've seen never put
627    // a length-carrying integer inside an ObjStm — they're tiny and
628    // the writer wants them resolvable without paying ObjStm decoding
629    // cost. If we hit one in the wild, surface a clear error rather
630    // than silently mis-resolving.
631    if let Some(XrefEntry::Compressed { .. }) = xref.entries.get(&length_ref.number) {
632        return Err(PdfError::other(format!(
633            "PDF reader: indirect /Length {length_ref:?} lives in an object stream \
634             — not supported (ISO 32000-1 §7.5.7); the length object should be a \
635             direct uncompressed integer"
636        )));
637    }
638    let off = xref.offset_of(length_ref).ok_or_else(|| {
639        PdfError::other(format!(
640            "PDF reader: indirect /Length {length_ref:?} (for stream \
641             {containing_stream:?}) is not in the xref table"
642        ))
643    })?;
644    let mut p = Parser::new(input);
645    p.lexer_mut().seek(off as usize);
646    // No resolver here — the length-carrying object must be a direct
647    // integer per §7.3.8.2 (Length is an `integer`, not a value-may-
648    // be-indirect entry). A stream-of-streams cycle is therefore
649    // statically impossible.
650    let (parsed_id, body) = p.parse_indirect()?;
651    if parsed_id != length_ref {
652        return Err(PdfError::other(format!(
653            "PDF reader: xref points to wrong object for indirect /Length — \
654             wanted {length_ref:?}, got {parsed_id:?}"
655        )));
656    }
657    match body {
658        Object::Integer(n) => Ok(n),
659        other => Err(PdfError::other(format!(
660            "PDF reader: indirect /Length {length_ref:?} target must be an \
661             integer (got {other:?})"
662        ))),
663    }
664}
665
666/// Resolve the trailer's `/Encrypt` reference + `/ID[0]` and try the
667/// supplied password against the standard security handler. Returns
668/// `Ok(None)` when the trailer has no `/Encrypt`. Errors when present
669/// but malformed or when the password fails to authenticate.
670fn build_crypt(
671    xref: &XrefTable,
672    input: &[u8],
673    password: &[u8],
674) -> Result<Option<StandardHandler>, PdfError> {
675    let encrypt_ref = xref.trailer.entries().iter().find(|(k, _)| k == "Encrypt");
676    let Some((_, encrypt_obj)) = encrypt_ref else {
677        return Ok(None);
678    };
679
680    // /Encrypt may be inline or an indirect reference. Resolve as needed.
681    let encrypt_dict = match encrypt_obj {
682        Object::Dict(d) => d.clone(),
683        Object::Reference(id) => {
684            let off = xref
685                .offset_of(*id)
686                .ok_or_else(|| PdfError::other("PDF reader: /Encrypt refers to missing object"))?;
687            let mut p = Parser::new(input);
688            p.lexer_mut().seek(off as usize);
689            let (_, body) = p.parse_indirect()?;
690            match body {
691                Object::Dict(d) => d,
692                other => {
693                    return Err(PdfError::other(format!(
694                        "PDF reader: /Encrypt must resolve to a dict (got {other:?})"
695                    )))
696                }
697            }
698        }
699        other => {
700            return Err(PdfError::other(format!(
701                "PDF reader: /Encrypt must be a dict or reference (got {other:?})"
702            )))
703        }
704    };
705
706    // /ID is required for encrypted PDFs (Algorithm 2 step (e)). The
707    // first element is the document-permanent identifier.
708    let id_obj = xref
709        .trailer
710        .entries()
711        .iter()
712        .find(|(k, _)| k == "ID")
713        .map(|(_, v)| v.clone())
714        .ok_or_else(|| PdfError::other("PDF reader: encrypted PDF missing /ID in trailer"))?;
715    let Object::Array(id_items) = id_obj else {
716        return Err(PdfError::other("PDF reader: /ID must be an array"));
717    };
718    if id_items.is_empty() {
719        return Err(PdfError::other("PDF reader: trailer /ID array is empty"));
720    }
721    let file_id = match &id_items[0] {
722        Object::LiteralString(s) | Object::HexString(s) => s.clone(),
723        other => {
724            return Err(PdfError::other(format!(
725                "PDF reader: /ID[0] must be a string (got {other:?})"
726            )))
727        }
728    };
729
730    let handler = open_with_password(&encrypt_dict, &file_id, password)?;
731    handler
732        .ok_or_else(|| {
733            PdfError::other("PDF reader: wrong password (or PDF requires owner password)")
734        })
735        .map(Some)
736}
737
738/// Public-key analogue of [`build_crypt`] — fetches the trailer's
739/// `/Encrypt` dict (resolving any indirect reference) and asks
740/// [`crate::pubsec::open_with_certificate`] to derive a
741/// [`StandardHandler`] from the user's certificate. Returns
742/// `Ok(None)` when no `/Encrypt` is present (so a non-encrypted PDF
743/// still opens via the certificate-based entry point).
744fn build_crypt_pubsec(
745    xref: &XrefTable,
746    input: &[u8],
747    credential: &PubSecCredential,
748    trust_store: Option<&TrustStore>,
749) -> Result<Option<StandardHandler>, PdfError> {
750    let encrypt_ref = xref.trailer.entries().iter().find(|(k, _)| k == "Encrypt");
751    let Some((_, encrypt_obj)) = encrypt_ref else {
752        return Ok(None);
753    };
754    let encrypt_dict = match encrypt_obj {
755        Object::Dict(d) => d.clone(),
756        Object::Reference(id) => {
757            let off = xref
758                .offset_of(*id)
759                .ok_or_else(|| PdfError::other("PDF reader: /Encrypt refers to missing object"))?;
760            let mut p = Parser::new(input);
761            p.lexer_mut().seek(off as usize);
762            let (_, body) = p.parse_indirect()?;
763            match body {
764                Object::Dict(d) => d,
765                other => {
766                    return Err(PdfError::other(format!(
767                        "PDF reader: /Encrypt must resolve to a dict (got {other:?})"
768                    )))
769                }
770            }
771        }
772        other => {
773            return Err(PdfError::other(format!(
774                "PDF reader: /Encrypt must be a dict or reference (got {other:?})"
775            )))
776        }
777    };
778    let handler = match trust_store {
779        Some(store) => open_with_certificate_and_trust_store(&encrypt_dict, credential, store)?,
780        None => open_with_certificate(&encrypt_dict, credential)?,
781    };
782    handler
783        .ok_or_else(|| {
784            PdfError::other(
785                "PDF reader: certificate did not match any recipient in /Recipients (round-10)",
786            )
787        })
788        .map(Some)
789}
790
791/// In-place decrypt: walk the parsed [`Object`] tree, decrypting every
792/// string and stream payload it contains. Encrypted PDFs only encrypt
793/// strings + streams — not numeric / boolean / name / array structure
794/// — so the recursion only mutates the leaf content of those two
795/// variants.
796///
797/// Per ISO 32000-1 §7.4.10 + §7.6.5, a stream's first `/Filter` may be
798/// `/Crypt` with `/DecodeParms /Name /Identity` to opt out of the
799/// per-object decryption — the bytes are then treated as cleartext.
800/// This is the standard "this stream is intentionally NOT encrypted
801/// even though the rest of the file is" override (used e.g. for
802/// document-level XMP metadata streams when `/EncryptMetadata false`
803/// can't be applied uniformly).
804fn decrypt_object_in_place(
805    obj: &mut Object,
806    id: ObjectId,
807    crypt: &StandardHandler,
808) -> Result<(), PdfError> {
809    match obj {
810        Object::LiteralString(s) | Object::HexString(s) => {
811            *s = crypt.decrypt_object(id, s)?;
812        }
813        Object::Array(items) => {
814            for item in items {
815                decrypt_object_in_place(item, id, crypt)?;
816            }
817        }
818        Object::Dict(d) => {
819            decrypt_dict_in_place(d, id, crypt)?;
820        }
821        Object::Stream(s) => {
822            // Stream body is decrypted; the `/Length` already reflects
823            // the encrypted length (which equals the cleartext length
824            // for RC4; for AES the cleartext is shorter by IV+padding).
825            // Recurse into the stream dict for any nested strings.
826            decrypt_dict_in_place(&mut s.dict, id, crypt)?;
827            // Per-stream /Crypt /Identity override: skip decryption.
828            if has_identity_crypt_filter(&s.dict) {
829                return Ok(());
830            }
831            // The stream's `/Filter` handling already decrypts before
832            // applying the filter — we decrypt the raw, still-filtered
833            // bytes here.
834            s.data = crypt.decrypt_object(id, &s.data)?;
835        }
836        // Numbers, booleans, names, null, references — not encrypted.
837        _ => {}
838    }
839    Ok(())
840}
841
842/// Detect a per-stream `/Filter [/Crypt …] /DecodeParms [<<…>>]` shape
843/// where the crypt-filter parms specify `/Name /Identity` — the ISO
844/// 32000-1 §7.6.5 opt-out for "this stream is NOT encrypted".
845///
846/// Accepts both the `/Filter /Crypt` (single name) and `/Filter
847/// [/Crypt …]` (array) forms; the matching `/DecodeParms` may be a
848/// single dict or an array of dicts (parallel to `/Filter`).
849fn has_identity_crypt_filter(dict: &Dict) -> bool {
850    let filter = dict
851        .entries()
852        .iter()
853        .find(|(k, _)| k == "Filter")
854        .map(|(_, v)| v);
855    let parms = dict
856        .entries()
857        .iter()
858        .find(|(k, _)| k == "DecodeParms")
859        .map(|(_, v)| v);
860
861    let crypt_pos: Option<usize> = match filter {
862        Some(Object::Name(s)) if s == "Crypt" => Some(0),
863        Some(Object::Array(items)) => items
864            .iter()
865            .position(|f| matches!(f, Object::Name(n) if n == "Crypt")),
866        _ => None,
867    };
868    let Some(idx) = crypt_pos else {
869        return false;
870    };
871
872    // The matching DecodeParms slot.
873    let parms_dict = match parms {
874        Some(Object::Dict(d)) if idx == 0 => Some(d.clone()),
875        Some(Object::Array(items)) => match items.get(idx) {
876            Some(Object::Dict(d)) => Some(d.clone()),
877            _ => None,
878        },
879        _ => None,
880    };
881    let Some(d) = parms_dict else {
882        // No parms → default Crypt filter. Default crypt filter
883        // /Name is /Identity per §7.4.10 (Table 24).
884        return true;
885    };
886    match d
887        .entries()
888        .iter()
889        .find(|(k, _)| k == "Name")
890        .map(|(_, v)| v)
891    {
892        Some(Object::Name(s)) => s == "Identity",
893        // Missing /Name defaults to /Identity (Table 24).
894        None => true,
895        _ => false,
896    }
897}
898
899fn decrypt_dict_in_place(
900    d: &mut Dict,
901    id: ObjectId,
902    crypt: &StandardHandler,
903) -> Result<(), PdfError> {
904    // We can't borrow_mut + iterate; reconstruct entries with the
905    // mutated values.
906    let mut new_entries: Vec<(String, Object)> = Vec::with_capacity(d.entries().len());
907    for (k, v) in d.entries() {
908        let mut v = v.clone();
909        decrypt_object_in_place(&mut v, id, crypt)?;
910        new_entries.push((k.clone(), v));
911    }
912    *d = Dict::default();
913    for (k, v) in new_entries {
914        d.set(&k, v);
915    }
916    Ok(())
917}
918
919/// Convenience — open + read straight into a [`Scene`] in pages mode.
920/// Inverse of [`crate::write_pdf_from_scene`] for PDFs the writer
921/// would produce.
922///
923/// Returns `Err` for:
924/// - Malformed xref / trailer (round-3 only handles plain xref tables;
925///   PDF 1.5+ /XRef streams surface as parse errors).
926/// - Encrypted PDFs that aren't openable with the empty user password
927///   (use [`read_pdf_to_scene_with_password`] instead).
928/// - Documents that decode to zero pages (catalog → pages tree
929///   walked but no Page leaves found).
930pub fn read_pdf_to_scene(input: &[u8]) -> Result<Scene, PdfError> {
931    read_pdf_to_scene_with_password(input, b"")
932}
933
934/// Like [`read_pdf_to_scene`] but accepts a user / owner password
935/// for encrypted PDFs.
936///
937/// Round-4 supports the standard security handler (R=2, R=3, R=4
938/// — RC4-40, RC4-128, AES-128 CBC). R=5 / R=6 (AES-256) (round 5).
939/// Public-key handlers go via
940/// [`read_pdf_to_scene_with_certificate`] (round 10).
941pub fn read_pdf_to_scene_with_password(input: &[u8], password: &[u8]) -> Result<Scene, PdfError> {
942    let reader = DocumentReader::open_with_password(input, password)?;
943    decode_to_scene(reader)
944}
945
946/// Like [`read_pdf_to_scene`] but unlocks a public-key-encrypted PDF
947/// (`adbe.pkcs7.s3` / `s4` / `s5`) using the supplied X.509
948/// certificate + RSA private key. Round-10 implementation; see
949/// [`crate::pubsec`] for SubFilter and crypt-method coverage.
950///
951/// Returns `PdfError::Other` when the PDF is encrypted but the
952/// supplied certificate doesn't match any recipient slot in
953/// `/Recipients` — analogous to the wrong-password error from
954/// [`read_pdf_to_scene_with_password`].
955pub fn read_pdf_to_scene_with_certificate(
956    input: &[u8],
957    credential: &PubSecCredential,
958) -> Result<Scene, PdfError> {
959    let reader = DocumentReader::open_with_certificate(input, credential)?;
960    decode_to_scene(reader)
961}
962
963/// Round-17: variant of [`read_pdf_to_scene_with_certificate`] that
964/// consults a [`TrustStore`] when a KARI envelope identifies the
965/// originator by `IssuerAndSerial` or `SubjectKeyIdentifier` (RFC 5652
966/// §6.2.2) instead of carrying its public point in-band.
967///
968/// In-band `OriginatorPublicKey` envelopes still work without
969/// consulting the trust store — the lookup path is only triggered for
970/// the long-term-cert forms.
971pub fn read_pdf_to_scene_with_certificate_and_trust_store(
972    input: &[u8],
973    credential: &PubSecCredential,
974    trust_store: &TrustStore,
975) -> Result<Scene, PdfError> {
976    let reader =
977        DocumentReader::open_with_certificate_and_trust_store(input, credential, trust_store)?;
978    decode_to_scene(reader)
979}
980
981fn decode_to_scene(mut reader: DocumentReader<'_>) -> Result<Scene, PdfError> {
982    // Catalog → /Pages reference.
983    let root_id = reader.xref.root()?;
984    let catalog = reader.resolve(root_id)?;
985    let Object::Dict(catalog) = catalog else {
986        return Err(PdfError::other(format!(
987            "PDF reader: /Root must be a dictionary (got {catalog:?})"
988        )));
989    };
990    let pages_ref = catalog
991        .entries()
992        .iter()
993        .find(|(k, _)| k == "Pages")
994        .map(|(_, v)| v.clone())
995        .ok_or_else(|| PdfError::other("PDF reader: catalog missing /Pages"))?;
996    let Object::Reference(pages_root_id) = pages_ref else {
997        return Err(PdfError::other(format!(
998            "PDF reader: catalog /Pages must be an indirect reference (got {pages_ref:?})"
999        )));
1000    };
1001
1002    // Walk the /Pages tree depth-first into a flat list of Page leaf
1003    // ids.
1004    let mut leaves = Vec::new();
1005    walk_pages_tree(&mut reader, pages_root_id, &mut leaves)?;
1006    if leaves.is_empty() {
1007        return Err(PdfError::other(
1008            "PDF reader: /Pages tree contained no Page leaves",
1009        ));
1010    }
1011
1012    // Resolve the catalog's optional-content state once (§8.11) — the
1013    // annotation-appearance path consults it for per-annotation /OC
1014    // visibility (§12.5.2 Table 164). A malformed /OCProperties is
1015    // treated as "not layered" rather than failing the whole decode.
1016    let optional_content = crate::reader::ocg::optional_content(&mut reader).unwrap_or(None);
1017
1018    // Decode each Page → oxideav_scene::Page.
1019    let mut scene_pages = Vec::with_capacity(leaves.len());
1020    for leaf_id in leaves {
1021        scene_pages.push(decode_page(
1022            &mut reader,
1023            leaf_id,
1024            optional_content.as_ref(),
1025        )?);
1026    }
1027
1028    // /Info → Metadata.
1029    let metadata = if let Some(info_id) = reader.xref.info() {
1030        let info = reader.resolve(info_id)?;
1031        decode_metadata(info)?
1032    } else {
1033        Metadata::default()
1034    };
1035
1036    Ok(Scene {
1037        pages: Some(scene_pages),
1038        metadata,
1039        ..Scene::default()
1040    })
1041}
1042
1043/// Maximum nesting depth of a §7.7.3.2 /Pages tree the reader will
1044/// follow. ISO 32000-1 does not specify a hard bound — but a
1045/// well-formed pages tree is balanced at most logarithmically in page
1046/// count, so a 256-level deep tree would map to more pages than any
1047/// sane consumer would attempt. Anything past this bound is treated
1048/// as a malformed tree (likely an attacker-shaped chain) and the
1049/// walker returns Err rather than blowing the call stack.
1050const MAX_PAGES_TREE_DEPTH: u32 = 256;
1051
1052fn walk_pages_tree(
1053    reader: &mut DocumentReader<'_>,
1054    node_id: ObjectId,
1055    out: &mut Vec<ObjectId>,
1056) -> Result<(), PdfError> {
1057    let mut visited = HashSet::new();
1058    walk_pages_tree_inner(reader, node_id, out, &mut visited, 0)
1059}
1060
1061fn walk_pages_tree_inner(
1062    reader: &mut DocumentReader<'_>,
1063    node_id: ObjectId,
1064    out: &mut Vec<ObjectId>,
1065    visited: &mut HashSet<ObjectId>,
1066    depth: u32,
1067) -> Result<(), PdfError> {
1068    if depth > MAX_PAGES_TREE_DEPTH {
1069        return Err(PdfError::other(format!(
1070            "PDF reader: /Pages tree exceeds maximum depth ({MAX_PAGES_TREE_DEPTH})"
1071        )));
1072    }
1073    // §7.7.3.2 says a /Pages node's /Kids array MUST NOT reference
1074    // an ancestor — but in malformed (or hostile) input it can, which
1075    // would loop the walker forever. Track every visited node and
1076    // refuse to re-enter one.
1077    if !visited.insert(node_id) {
1078        return Err(PdfError::other(format!(
1079            "PDF reader: /Pages tree contains a cycle at {node_id:?}"
1080        )));
1081    }
1082    let node = reader.resolve(node_id)?;
1083    let Object::Dict(d) = node else {
1084        return Err(PdfError::other(format!(
1085            "PDF reader: pages-tree node {node_id:?} is not a dict"
1086        )));
1087    };
1088    let kind = d
1089        .entries()
1090        .iter()
1091        .find(|(k, _)| k == "Type")
1092        .and_then(|(_, v)| match v {
1093            Object::Name(s) => Some(s.as_str()),
1094            _ => None,
1095        });
1096    match kind {
1097        Some("Page") => {
1098            out.push(node_id);
1099            Ok(())
1100        }
1101        Some("Pages") => {
1102            let kids = d
1103                .entries()
1104                .iter()
1105                .find(|(k, _)| k == "Kids")
1106                .map(|(_, v)| v.clone())
1107                .ok_or_else(|| {
1108                    PdfError::other(format!("PDF reader: /Pages node {node_id:?} missing /Kids"))
1109                })?;
1110            let Object::Array(items) = kids else {
1111                return Err(PdfError::other(format!(
1112                    "PDF reader: /Kids must be an array on {node_id:?}"
1113                )));
1114            };
1115            for item in items {
1116                if let Object::Reference(id) = item {
1117                    walk_pages_tree_inner(reader, id, out, visited, depth + 1)?;
1118                }
1119            }
1120            Ok(())
1121        }
1122        _ => Err(PdfError::other(format!(
1123            "PDF reader: pages-tree node {node_id:?} has unknown /Type {kind:?}"
1124        ))),
1125    }
1126}
1127
1128/// Maximum `/Parent` chain length walked when resolving an inheritable
1129/// page attribute (§7.7.3.4). A well-formed page tree is shallow; this
1130/// ceiling bounds a malformed or cyclic chain so resolution always
1131/// terminates even though the per-node visited set already breaks a
1132/// direct cycle.
1133const MAX_PAGE_TREE_DEPTH: usize = 64;
1134
1135/// Resolve an inheritable page attribute (`MediaBox`, `Resources`,
1136/// `CropBox`, or `Rotate`, §7.7.3.4 Table 30) for a leaf page. The
1137/// page dictionary is checked first; when it omits the key the
1138/// `/Parent` chain is walked up the page tree and the first ancestor
1139/// that carries the key supplies the value. Returns `Ok(None)` when
1140/// neither the page nor any ancestor defines it (the caller then
1141/// applies the attribute's default).
1142///
1143/// The walk is bounded by [`MAX_PAGE_TREE_DEPTH`] and cycle-guarded by
1144/// a visited-id set so a self-referential `/Parent` (malformed input)
1145/// can't loop. The returned `Object` is the value verbatim (an
1146/// indirect reference is *not* dereferenced here — the caller's
1147/// existing one-hop resolution handles that, matching the prior
1148/// directly-attached path).
1149fn resolve_inheritable_attr(
1150    reader: &mut DocumentReader<'_>,
1151    page_dict: &Dict,
1152    key: &str,
1153) -> Result<Option<Object>, PdfError> {
1154    if let Some((_, v)) = page_dict.entries().iter().find(|(k, _)| k == key) {
1155        return Ok(Some(v.clone()));
1156    }
1157    // Climb `/Parent` until the key is found, the chain ends, or the
1158    // depth / cycle guard fires.
1159    let mut parent = page_dict
1160        .entries()
1161        .iter()
1162        .find(|(k, _)| k == "Parent")
1163        .map(|(_, v)| v.clone());
1164    let mut visited: HashSet<ObjectId> = HashSet::new();
1165    let mut depth = 0;
1166    while let Some(Object::Reference(parent_id)) = parent {
1167        if depth >= MAX_PAGE_TREE_DEPTH || !visited.insert(parent_id) {
1168            break;
1169        }
1170        depth += 1;
1171        let Object::Dict(node) = reader.resolve(parent_id)? else {
1172            break;
1173        };
1174        if let Some((_, v)) = node.entries().iter().find(|(k, _)| k == key) {
1175            return Ok(Some(v.clone()));
1176        }
1177        parent = node
1178            .entries()
1179            .iter()
1180            .find(|(k, _)| k == "Parent")
1181            .map(|(_, v)| v.clone());
1182    }
1183    Ok(None)
1184}
1185
1186fn decode_page(
1187    reader: &mut DocumentReader<'_>,
1188    page_id: ObjectId,
1189    optional_content: Option<&crate::reader::ocg::OptionalContent>,
1190) -> Result<Page, PdfError> {
1191    let page_obj = reader.resolve(page_id)?;
1192    let Object::Dict(page_dict) = page_obj else {
1193        return Err(PdfError::other(format!(
1194            "PDF reader: page {page_id:?} is not a dict"
1195        )));
1196    };
1197
1198    // /MediaBox is required for the leaf page or inherited from an
1199    // ancestor /Pages node (§7.7.3.4 — `MediaBox` is one of the four
1200    // inheritable page attributes alongside `Resources`, `CropBox`,
1201    // and `Rotate`). Walk the `/Parent` chain when the leaf omits it.
1202    let media_box = resolve_inheritable_attr(reader, &page_dict, "MediaBox")?;
1203    let (width, height) = match media_box {
1204        Some(Object::Array(items)) if items.len() == 4 => {
1205            let llx = number_to_f32(&items[0])?;
1206            let lly = number_to_f32(&items[1])?;
1207            let urx = number_to_f32(&items[2])?;
1208            let ury = number_to_f32(&items[3])?;
1209            ((urx - llx).abs(), (ury - lly).abs())
1210        }
1211        Some(other) => {
1212            return Err(PdfError::other(format!(
1213                "PDF reader: /MediaBox must be a 4-array (got {other:?})"
1214            )));
1215        }
1216        None => {
1217            // No /MediaBox on the page or any ancestor — default to A4
1218            // portrait so the page object is still constructible.
1219            (595.0, 842.0)
1220        }
1221    };
1222
1223    // /Contents is one stream OR an array of streams. Concatenate.
1224    let contents_obj = page_dict
1225        .entries()
1226        .iter()
1227        .find(|(k, _)| k == "Contents")
1228        .map(|(_, v)| v.clone());
1229    let content_bytes = match contents_obj {
1230        Some(Object::Reference(id)) => extract_stream_data(reader, id)?,
1231        Some(Object::Array(items)) => {
1232            let mut all = Vec::new();
1233            for item in items {
1234                if let Object::Reference(id) = item {
1235                    all.extend_from_slice(&extract_stream_data(reader, id)?);
1236                    all.push(b'\n');
1237                }
1238            }
1239            all
1240        }
1241        Some(other) => {
1242            return Err(PdfError::other(format!(
1243                "PDF reader: /Contents must be a Stream or array (got {other:?})"
1244            )));
1245        }
1246        None => Vec::new(),
1247    };
1248
1249    // /Resources is a dictionary or an indirect reference to one
1250    // (§7.8.3 Table 33). It is inheritable (§7.7.3.4): a page that
1251    // omits it takes the nearest ancestor /Pages node's /Resources, so
1252    // documents that hang one resource dictionary on the page-tree root
1253    // resolve their fonts / XObjects / shadings instead of rendering
1254    // empty.
1255    let resources_obj = resolve_inheritable_attr(reader, &page_dict, "Resources")?;
1256    let resources_dict = match resources_obj {
1257        Some(Object::Reference(id)) => match reader.resolve(id)? {
1258            Object::Dict(d) => Some(d),
1259            _ => None,
1260        },
1261        Some(Object::Dict(d)) => Some(d),
1262        _ => None,
1263    };
1264    let ext_gstate_dict = if let Some(rdict) = resources_dict.as_ref() {
1265        resolve_ext_gstate(reader, rdict)?
1266    } else {
1267        None
1268    };
1269    let fonts_dict = if let Some(rdict) = resources_dict.as_ref() {
1270        resolve_font_resources(reader, rdict)?
1271    } else {
1272        None
1273    };
1274    let shading_dict = if let Some(rdict) = resources_dict.as_ref() {
1275        resolve_shading_resources(reader, rdict)?
1276    } else {
1277        None
1278    };
1279    let color_space_dict = if let Some(rdict) = resources_dict.as_ref() {
1280        resolve_color_space_resources(reader, rdict)?
1281    } else {
1282        None
1283    };
1284    let properties_dict = if let Some(rdict) = resources_dict.as_ref() {
1285        resolve_properties_resources(reader, rdict)?
1286    } else {
1287        None
1288    };
1289    let xobject_forms = if let Some(rdict) = resources_dict.as_ref() {
1290        let mut seen = HashSet::new();
1291        resolve_xobject_forms(reader, rdict, 0, &mut seen)?
1292    } else {
1293        None
1294    };
1295    let pattern_dict = if let Some(rdict) = resources_dict.as_ref() {
1296        resolve_pattern_resources(reader, rdict)?
1297    } else {
1298        None
1299    };
1300    let tiling_patterns = if let Some(rdict) = resources_dict.as_ref() {
1301        resolve_tiling_patterns(reader, rdict, 0)?
1302    } else {
1303        None
1304    };
1305    let type3_fonts = if let Some(rdict) = resources_dict.as_ref() {
1306        resolve_type3_fonts(reader, rdict, 0)?
1307    } else {
1308        None
1309    };
1310
1311    let parsed = parse_content_stream_full_with_type3(
1312        &content_bytes,
1313        ext_gstate_dict.as_ref(),
1314        fonts_dict.as_ref(),
1315        shading_dict.as_ref(),
1316        color_space_dict.as_ref(),
1317        properties_dict.as_ref(),
1318        xobject_forms.as_ref(),
1319        pattern_dict.as_ref(),
1320        tiling_patterns.as_ref(),
1321        type3_fonts.as_ref(),
1322    )?;
1323    let mut root = parsed.root;
1324
1325    // §12.5.5 — paint each /Annots annotation's applicable appearance
1326    // stream on top of the page content (the appearance composites
1327    // over "the page content along with any previously painted
1328    // annotations", so array order is paint order).
1329    let annot_groups = resolve_annotation_appearances(reader, &page_dict, optional_content)?;
1330    if !annot_groups.is_empty() {
1331        // The parsed page root is normally a bare container; if it
1332        // carries its own transform / clip / opacity, nest it so the
1333        // annotation groups (positioned in default user space) don't
1334        // inherit page-content state.
1335        if root.transform != Transform2D::identity() || root.clip.is_some() || root.opacity != 1.0 {
1336            root = Group {
1337                children: vec![Node::Group(root)],
1338                ..Group::default()
1339            };
1340        }
1341        root.children
1342            .extend(annot_groups.into_iter().map(Node::Group));
1343    }
1344
1345    let mut page = Page::new(width, height);
1346    // /Rotate (§7.7.3.3 Table 30) — degrees clockwise, a multiple of
1347    // 90, inheritable. Normalise any multiple of 90 (incl. negative /
1348    // > 360 values some producers emit) into the canonical 0 / 90 /
1349    // 180 / 270 the scene `Page::orientation` carries; a non-multiple
1350    // of 90 is malformed and left at the default 0.
1351    if let Some(Object::Integer(deg)) = resolve_inheritable_attr(reader, &page_dict, "Rotate")? {
1352        if deg % 90 == 0 {
1353            page.orientation = (deg.rem_euclid(360)) as u16;
1354        }
1355    }
1356    page.content = VectorFrame {
1357        width,
1358        height,
1359        view_box: None,
1360        root,
1361        pts: None,
1362        time_base: TimeBase::new(1, 1),
1363    };
1364    Ok(page)
1365}
1366
1367/// Resolve a page's `/Resources /ExtGState` subdictionary into a
1368/// fully-dereferenced [`Dict`] (each per-name `/GSx` value is itself
1369/// resolved into a direct `Dict` if it was an indirect reference).
1370/// Returns `Ok(None)` when the resources dict carries no `/ExtGState`
1371/// entry — the most common case for documents that don't use the
1372/// `gs` operator.
1373///
1374/// Only direct + single-hop indirect dicts are surfaced. A malformed
1375/// entry (non-dict resolved value, deeply nested indirection beyond a
1376/// single hop) is silently dropped so a `gs` against that name
1377/// behaves as a tolerated no-op, matching the round-3 fallback.
1378fn resolve_ext_gstate(
1379    reader: &mut DocumentReader<'_>,
1380    resources: &Dict,
1381) -> Result<Option<Dict>, PdfError> {
1382    let ext_obj = resources
1383        .entries()
1384        .iter()
1385        .find(|(k, _)| k == "ExtGState")
1386        .map(|(_, v)| v.clone());
1387    let ext_obj = match ext_obj {
1388        Some(Object::Reference(id)) => reader.resolve(id)?,
1389        Some(other) => other,
1390        None => return Ok(None),
1391    };
1392    let Object::Dict(ext_dict) = ext_obj else {
1393        return Ok(None);
1394    };
1395    // Walk each per-name entry; resolve a one-hop indirect reference
1396    // into its target dict so the content-stream parser can read entry
1397    // keys directly without touching the reader.
1398    let mut out = Dict::new();
1399    for (name, value) in ext_dict.entries() {
1400        let resolved = match value {
1401            Object::Reference(id) => reader.resolve(*id)?,
1402            other => other.clone(),
1403        };
1404        if let Object::Dict(d) = resolved {
1405            out.set(name, Object::Dict(d));
1406        }
1407    }
1408    Ok(Some(out))
1409}
1410
1411/// Resolve a page's `/Resources /Font` subdictionary into a
1412/// fully-dereferenced [`Dict`] (each per-name `/Fx` value is itself
1413/// resolved into a direct `Dict` if it was an indirect reference).
1414/// Returns `Ok(None)` when the resources dict carries no `/Font`
1415/// entry — the most common case for documents that don't use any
1416/// text-showing operator (`Tj` / `TJ` / `'` / `"`).
1417///
1418/// Mirrors [`resolve_ext_gstate`]'s shape so the round-128 `Tj` /
1419/// `TJ` plumbing slots into the same single-hop indirect dereference
1420/// path the round-125 `gs` resolver uses (ISO 32000-1 §7.8.3 + Table 33
1421/// for the `/Resources` shape, §9.5 + §9.6 + §9.7 for fonts).
1422///
1423/// Only direct + single-hop indirect dicts are surfaced. A malformed
1424/// entry (non-dict resolved value, deeply nested indirection beyond a
1425/// single hop) is silently dropped so a `Tj` against that font name
1426/// behaves as a "font unresolved" event (the show still fires with
1427/// `font_dict = None` so the consumer knows what happened), matching
1428/// the round-3 tolerance stance.
1429fn resolve_font_resources(
1430    reader: &mut DocumentReader<'_>,
1431    resources: &Dict,
1432) -> Result<Option<Dict>, PdfError> {
1433    let font_obj = resources
1434        .entries()
1435        .iter()
1436        .find(|(k, _)| k == "Font")
1437        .map(|(_, v)| v.clone());
1438    let font_obj = match font_obj {
1439        Some(Object::Reference(id)) => reader.resolve(id)?,
1440        Some(other) => other,
1441        None => return Ok(None),
1442    };
1443    let Object::Dict(font_dict) = font_obj else {
1444        return Ok(None);
1445    };
1446    let mut out = Dict::new();
1447    for (name, value) in font_dict.entries() {
1448        let resolved = match value {
1449            Object::Reference(id) => reader.resolve(*id)?,
1450            other => other.clone(),
1451        };
1452        if let Object::Dict(mut d) = resolved {
1453            // Deep-resolve the entries the content walker's §9.4.4 text
1454            // advance needs (`/Widths`, `/FontDescriptor /MissingWidth`,
1455            // and for Type0 the descendant CIDFont's `/W` / `/DW`) so
1456            // they are direct numerics / arrays rather than indirect
1457            // references when `build_font_metrics` reads them.
1458            resolve_font_widths(reader, &mut d)?;
1459            out.set(name, Object::Dict(d));
1460        }
1461    }
1462    Ok(Some(out))
1463}
1464
1465/// Dereference the width-related entries of a single resolved font
1466/// dictionary so the content walker's §9.4.4 advance sees direct
1467/// values. Mutates `font` in place:
1468///
1469/// * `/Widths` — an indirect array reference is replaced by the
1470///   resolved `Object::Array`.
1471/// * `/FontDescriptor` — resolved to a direct dict (its
1472///   `/MissingWidth` is read by the walker).
1473/// * `/DescendantFonts` — for Type0 fonts the (usually one-element)
1474///   array is resolved, its CIDFont dict dereferenced, and that
1475///   CIDFont's `/W` array dereferenced. The descendant is stored back
1476///   as a direct `Object::Dict` so `build_cid_metrics` finds it.
1477fn resolve_font_widths(reader: &mut DocumentReader<'_>, font: &mut Dict) -> Result<(), PdfError> {
1478    // /Widths (simple fonts) — resolve an indirect array.
1479    if let Some(Object::Reference(id)) =
1480        font.entries()
1481            .iter()
1482            .find_map(|(k, v)| if k == "Widths" { Some(v.clone()) } else { None })
1483    {
1484        let resolved = reader.resolve(id)?;
1485        font.set("Widths", resolved);
1486    }
1487    // /FontDescriptor — resolve to a direct dict for /MissingWidth.
1488    if let Some(Object::Reference(id)) = font.entries().iter().find_map(|(k, v)| {
1489        if k == "FontDescriptor" {
1490            Some(v.clone())
1491        } else {
1492            None
1493        }
1494    }) {
1495        if let Ok(Object::Dict(d)) = reader.resolve(id) {
1496            font.set("FontDescriptor", Object::Dict(d));
1497        }
1498    }
1499    // /DescendantFonts (Type0) — resolve the array + CIDFont + its /W.
1500    let descendant_ref = font.entries().iter().find_map(|(k, v)| {
1501        if k == "DescendantFonts" {
1502            Some(v.clone())
1503        } else {
1504            None
1505        }
1506    });
1507    if let Some(obj) = descendant_ref {
1508        let array_obj = match obj {
1509            Object::Reference(id) => reader.resolve(id)?,
1510            other => other,
1511        };
1512        // Pull the first dict (the sole CIDFont) out of the array.
1513        let cid_ref = match array_obj {
1514            Object::Array(items) => items.into_iter().next(),
1515            Object::Dict(d) => Some(Object::Dict(d)),
1516            _ => None,
1517        };
1518        if let Some(cid_obj) = cid_ref {
1519            let cid_obj = match cid_obj {
1520                Object::Reference(id) => reader.resolve(id)?,
1521                other => other,
1522            };
1523            if let Object::Dict(mut cid_font) = cid_obj {
1524                // Resolve the CIDFont's /W array (often indirect).
1525                if let Some(Object::Reference(id)) = cid_font.entries().iter().find_map(|(k, v)| {
1526                    if k == "W" {
1527                        Some(v.clone())
1528                    } else {
1529                        None
1530                    }
1531                }) {
1532                    let resolved = reader.resolve(id)?;
1533                    cid_font.set("W", resolved);
1534                }
1535                font.set("DescendantFonts", Object::Dict(cid_font));
1536            }
1537        }
1538    }
1539    Ok(())
1540}
1541
1542/// Resolve a page's `/Resources /Shading` subdictionary into a
1543/// fully-dereferenced [`Dict`] (each per-name `/Shx` value is itself
1544/// resolved into a direct `Object::Dict` if it was an indirect
1545/// reference, *and* indirect `Object::Stream` values are surfaced
1546/// as their stream dictionary — Type 4..7 shadings are stream
1547/// objects per §8.7.4.5 Tables 82..86 whose dictionary holds the
1548/// Table 78 + per-type entries).
1549///
1550/// Returns `Ok(None)` when the resources dict carries no `/Shading`
1551/// entry — the most common case for documents that don't use the
1552/// `sh` operator (gradients via `Pattern Type 2` go through
1553/// `/Resources /Pattern` instead).
1554///
1555/// Mirrors [`resolve_ext_gstate`] / [`resolve_font_resources`] —
1556/// single-hop indirect dereference, malformed entries silently
1557/// dropped so a `sh` against the missing name still emits the event
1558/// with `shading_dict = None`.
1559fn resolve_shading_resources(
1560    reader: &mut DocumentReader<'_>,
1561    resources: &Dict,
1562) -> Result<Option<Dict>, PdfError> {
1563    let shading_obj = resources
1564        .entries()
1565        .iter()
1566        .find(|(k, _)| k == "Shading")
1567        .map(|(_, v)| v.clone());
1568    let shading_obj = match shading_obj {
1569        Some(Object::Reference(id)) => reader.resolve(id)?,
1570        Some(other) => other,
1571        None => return Ok(None),
1572    };
1573    let Object::Dict(shading_dict) = shading_obj else {
1574        return Ok(None);
1575    };
1576    let mut out = Dict::new();
1577    for (name, value) in shading_dict.entries() {
1578        let resolved = match value {
1579            Object::Reference(id) => reader.resolve(*id)?,
1580            other => other.clone(),
1581        };
1582        // Type 1..3 shadings are bare dictionaries (§8.7.4.5.2..4);
1583        // Type 4..7 shadings are streams whose dictionary holds the
1584        // same Table 78 + per-type entries plus the bit-packed mesh
1585        // geometry payload in the stream body. For a stream-shaped
1586        // shading the decoded body is folded into the surfaced
1587        // dictionary under the synthetic `__MeshData` key (a
1588        // `HexString`), mirroring the Type 0 function `__Samples`
1589        // handling, so the content parser can interpret the mesh
1590        // without re-fetching the stream. Either shape's optional
1591        // `/Function` entry (§8.7.4.5.5..8 — a parametric colour
1592        // transform shared by mesh types) is prepared in place so the
1593        // parser sees a self-contained, evaluable function.
1594        let d = match resolved {
1595            Object::Dict(d) => Some(d),
1596            Object::Stream(s) => {
1597                let mesh = decode_stream(&s)?;
1598                let mut d = s.dict;
1599                d.set("__MeshData", Object::HexString(mesh));
1600                Some(d)
1601            }
1602            _ => None,
1603        };
1604        if let Some(mut d) = d {
1605            if let Some((_, fobj)) = d
1606                .entries()
1607                .iter()
1608                .find(|(k, _)| k == "Function")
1609                .map(|(k, v)| (k.clone(), v.clone()))
1610            {
1611                // §8.7.4.5.5: `/Function` is either a single 1-in /
1612                // n-out function or an array of n 1-in / 1-out
1613                // functions. A reference may stand in for either; one
1614                // hop is dereferenced before deciding which shape it
1615                // is, then each element of an array is prepared
1616                // individually.
1617                let fobj = match fobj {
1618                    Object::Reference(id) => reader.resolve(id)?,
1619                    other => other,
1620                };
1621                let prepared = match fobj {
1622                    Object::Array(items) => {
1623                        let mut prepared = Vec::with_capacity(items.len());
1624                        for f in items {
1625                            prepared.push(prepare_function_object(reader, f)?);
1626                        }
1627                        Object::Array(prepared)
1628                    }
1629                    other => prepare_function_object(reader, other)?,
1630                };
1631                d.set("Function", prepared);
1632            }
1633            out.set(name, Object::Dict(d));
1634        }
1635    }
1636    Ok(Some(out))
1637}
1638
1639/// Resolve a page's `/Resources /Pattern` subdictionary (§8.7.3) into a
1640/// fully-dereferenced [`Dict`] the content parser can interpret for
1641/// `scn`/`SCN` shading-pattern fills.
1642///
1643/// Returns `Ok(None)` when the resources dict carries no `/Pattern`
1644/// entry. Each per-name value is dereferenced to a pattern dictionary
1645/// (a tiling pattern, `/PatternType 1`, is a *stream*; a shading
1646/// pattern, `/PatternType 2`, is a bare dictionary). For a shading
1647/// pattern the nested `/Shading` is dereferenced and, when it is an
1648/// axial / radial shading carrying a `/Function`, that function is
1649/// prepared in place (sample bodies / nested references resolved) so the
1650/// content parser sees a self-contained, evaluable shading — mirroring
1651/// [`resolve_shading_resources`]. Tiling patterns are surfaced verbatim
1652/// (the parser renders no scene primitive for them this round). The pattern's `/Matrix` is left as-is.
1653fn resolve_pattern_resources(
1654    reader: &mut DocumentReader<'_>,
1655    resources: &Dict,
1656) -> Result<Option<Dict>, PdfError> {
1657    let pat_obj = resources
1658        .entries()
1659        .iter()
1660        .find(|(k, _)| k == "Pattern")
1661        .map(|(_, v)| v.clone());
1662    let pat_obj = match pat_obj {
1663        Some(Object::Reference(id)) => reader.resolve(id)?,
1664        Some(other) => other,
1665        None => return Ok(None),
1666    };
1667    let Object::Dict(pat_dict) = pat_obj else {
1668        return Ok(None);
1669    };
1670    let mut out = Dict::new();
1671    for (name, value) in pat_dict.entries() {
1672        let resolved = match value {
1673            Object::Reference(id) => reader.resolve(*id)?,
1674            other => other.clone(),
1675        };
1676        // A shading pattern is a bare dict; a tiling pattern is a stream
1677        // (its dict still carries /PatternType 1). Surface the dict for
1678        // either shape; only the shading-pattern path is interpreted.
1679        let mut d = match resolved {
1680            Object::Dict(d) => d,
1681            Object::Stream(s) => s.dict,
1682            _ => continue,
1683        };
1684        // For a shading pattern (PatternType 2), dereference + prepare
1685        // the nested /Shading so the content parser sees a self-contained
1686        // shading dictionary with an evaluable /Function.
1687        if let Some((_, sh)) = d
1688            .entries()
1689            .iter()
1690            .find(|(k, _)| k == "Shading")
1691            .map(|(k, v)| (k.clone(), v.clone()))
1692        {
1693            let sh = match sh {
1694                Object::Reference(id) => reader.resolve(id)?,
1695                other => other,
1696            };
1697            if let Object::Dict(mut shading) = sh {
1698                if let Some((_, fobj)) = shading
1699                    .entries()
1700                    .iter()
1701                    .find(|(k, _)| k == "Function")
1702                    .map(|(k, v)| (k.clone(), v.clone()))
1703                {
1704                    let fobj = match fobj {
1705                        Object::Reference(id) => reader.resolve(id)?,
1706                        other => other,
1707                    };
1708                    let prepared = match fobj {
1709                        Object::Array(items) => {
1710                            let mut prepared = Vec::with_capacity(items.len());
1711                            for f in items {
1712                                prepared.push(prepare_function_object(reader, f)?);
1713                            }
1714                            Object::Array(prepared)
1715                        }
1716                        other => prepare_function_object(reader, other)?,
1717                    };
1718                    shading.set("Function", prepared);
1719                }
1720                d.set("Shading", Object::Dict(shading));
1721            }
1722        }
1723        out.set(name, Object::Dict(d));
1724    }
1725    Ok(Some(out))
1726}
1727
1728/// Resolve a page's `/Resources /Pattern` subdictionary into the
1729/// pre-parsed `/PatternType 1` tiling patterns (§8.7.3) the content
1730/// walker replicates across `scn`/`SCN` tiling-pattern fills. Each
1731/// tiling pattern is a *stream*; its content stream is decoded and
1732/// parsed into a [`Group`] against the pattern's own `/Resources`
1733/// (mirroring [`resolve_one_form_xobject`]), and the `/BBox`, `/XStep`,
1734/// `/YStep`, `/Matrix`, and `/PaintType` (Table 75) are captured.
1735///
1736/// Returns `Ok(None)` when the resources dict carries no `/Pattern`
1737/// entry or no entry is a renderable tiling pattern (a shading pattern,
1738/// `/PatternType 2`, is handled separately by
1739/// [`resolve_pattern_resources`]). A tiling pattern whose cell can't be
1740/// decoded, whose `/XStep` / `/YStep` is zero/absent, or whose `/BBox`
1741/// is malformed is skipped (its fill keeps the conservative black
1742/// fallback). `depth` bounds nested-Form recursion inside a cell.
1743fn resolve_tiling_patterns(
1744    reader: &mut DocumentReader<'_>,
1745    resources: &Dict,
1746    depth: usize,
1747) -> Result<Option<BTreeMap<String, TilingPattern>>, PdfError> {
1748    if depth >= MAX_XOBJECT_DEPTH {
1749        return Ok(None);
1750    }
1751    let pat_obj = resources
1752        .entries()
1753        .iter()
1754        .find(|(k, _)| k == "Pattern")
1755        .map(|(_, v)| v.clone());
1756    let pat_obj = match pat_obj {
1757        Some(Object::Reference(id)) => reader.resolve(id)?,
1758        Some(other) => other,
1759        None => return Ok(None),
1760    };
1761    let Object::Dict(pat_dict) = pat_obj else {
1762        return Ok(None);
1763    };
1764    let mut out: BTreeMap<String, TilingPattern> = BTreeMap::new();
1765    for (name, value) in pat_dict.entries() {
1766        let resolved = match value {
1767            Object::Reference(id) => reader.resolve(*id)?,
1768            other => other.clone(),
1769        };
1770        // Only a tiling pattern is a stream; a shading pattern is a bare
1771        // dict (handled elsewhere).
1772        let Object::Stream(stream) = resolved else {
1773            continue;
1774        };
1775        if dict_int(&stream.dict, "PatternType") != Some(1) {
1776            continue;
1777        }
1778        if let Some(tp) = resolve_one_tiling_pattern(reader, &stream, depth)? {
1779            out.insert(name.clone(), tp);
1780        }
1781    }
1782    if out.is_empty() {
1783        Ok(None)
1784    } else {
1785        Ok(Some(out))
1786    }
1787}
1788
1789/// Parse one `/PatternType 1` tiling pattern stream into a
1790/// [`TilingPattern`] (§8.7.3.1 Table 75). Returns `Ok(None)` when the
1791/// cell content can't be decoded / parses to nothing, the required
1792/// `/XStep` / `/YStep` is missing or zero, or the `/BBox` is malformed.
1793fn resolve_one_tiling_pattern(
1794    reader: &mut DocumentReader<'_>,
1795    stream: &Stream,
1796    depth: usize,
1797) -> Result<Option<TilingPattern>, PdfError> {
1798    let bbox = match read_rect(&stream.dict, "BBox") {
1799        Some(r) => r,
1800        None => return Ok(None),
1801    };
1802    let xstep = match dict_num(&stream.dict, "XStep") {
1803        Some(v) if v.is_finite() && v != 0.0 => v,
1804        _ => return Ok(None),
1805    };
1806    let ystep = match dict_num(&stream.dict, "YStep") {
1807        Some(v) if v.is_finite() && v != 0.0 => v,
1808        _ => return Ok(None),
1809    };
1810    let paint_type = dict_int(&stream.dict, "PaintType").unwrap_or(1);
1811    let matrix = form_matrix(&stream.dict);
1812
1813    let content_bytes = match decode_stream(stream) {
1814        Ok(b) => b,
1815        Err(_) => return Ok(None),
1816    };
1817
1818    // The cell's own /Resources (Table 75 — required, but tolerate
1819    // absence the same way a Form XObject does).
1820    let cell_resources = match stream.dict.entries().iter().find(|(k, _)| k == "Resources") {
1821        Some((_, Object::Reference(id))) => match reader.resolve(*id)? {
1822            Object::Dict(d) => Some(d),
1823            _ => None,
1824        },
1825        Some((_, Object::Dict(d))) => Some(d.clone()),
1826        _ => None,
1827    };
1828
1829    let ext_gstate_dict = match cell_resources.as_ref() {
1830        Some(r) => resolve_ext_gstate(reader, r)?,
1831        None => None,
1832    };
1833    let fonts_dict = match cell_resources.as_ref() {
1834        Some(r) => resolve_font_resources(reader, r)?,
1835        None => None,
1836    };
1837    let shading_dict = match cell_resources.as_ref() {
1838        Some(r) => resolve_shading_resources(reader, r)?,
1839        None => None,
1840    };
1841    let color_space_dict = match cell_resources.as_ref() {
1842        Some(r) => resolve_color_space_resources(reader, r)?,
1843        None => None,
1844    };
1845    let properties_dict = match cell_resources.as_ref() {
1846        Some(r) => resolve_properties_resources(reader, r)?,
1847        None => None,
1848    };
1849    let mut seen = HashSet::new();
1850    let nested_forms = match cell_resources.as_ref() {
1851        Some(r) => resolve_xobject_forms(reader, r, depth + 1, &mut seen)?,
1852        None => None,
1853    };
1854    let pattern_dict = match cell_resources.as_ref() {
1855        Some(r) => resolve_pattern_resources(reader, r)?,
1856        None => None,
1857    };
1858    // A cell may itself paint with a tiling pattern (§8.7.2 NOTE 1 — an
1859    // inner pattern is local to the outer cell); recurse with a deeper
1860    // bound so a self-referential pattern terminates.
1861    let nested_tiling = match cell_resources.as_ref() {
1862        Some(r) => resolve_tiling_patterns(reader, r, depth + 1)?,
1863        None => None,
1864    };
1865
1866    let parsed = parse_content_stream_full_with_tiling(
1867        &content_bytes,
1868        ext_gstate_dict.as_ref(),
1869        fonts_dict.as_ref(),
1870        shading_dict.as_ref(),
1871        color_space_dict.as_ref(),
1872        properties_dict.as_ref(),
1873        nested_forms.as_ref(),
1874        pattern_dict.as_ref(),
1875        nested_tiling.as_ref(),
1876    )?;
1877    if parsed.root.children.is_empty() {
1878        return Ok(None);
1879    }
1880    Ok(Some(TilingPattern {
1881        cell: parsed.root,
1882        bbox,
1883        xstep,
1884        ystep,
1885        matrix,
1886        paint_type,
1887    }))
1888}
1889
1890/// Resolve every Type 3 font (§9.6.5) in a `/Resources /Font`
1891/// subdictionary into a [`Type3Font`], keyed by font resource name.
1892///
1893/// For each `/Subtype /Type3` font dictionary this:
1894///
1895/// * reads `/FontMatrix` (glyph→text space, default `[0.001 0 0 0.001
1896///   0 0]`);
1897/// * parses `/Encoding /Differences` into a code→glyph-name map
1898///   (§9.6.6.1 — a Type 3 font's encoding is given entirely by
1899///   `/Differences`, Table 112);
1900/// * decodes each `/CharProcs` glyph-description stream and parses it
1901///   against the font's own `/Resources` (falling back to the page's
1902///   when absent, §9.6.5 Table 112) into a [`Group`].
1903///
1904/// Non-Type3 fonts and any glyph that fails to decode are skipped. A
1905/// font with no usable glyphs is omitted from the map. `depth` bounds
1906/// the Form / pattern recursion a glyph description may trigger.
1907fn resolve_type3_fonts(
1908    reader: &mut DocumentReader<'_>,
1909    resources: &Dict,
1910    depth: usize,
1911) -> Result<Option<BTreeMap<String, Type3Font>>, PdfError> {
1912    if depth >= MAX_XOBJECT_DEPTH {
1913        return Ok(None);
1914    }
1915    let font_obj = resources
1916        .entries()
1917        .iter()
1918        .find(|(k, _)| k == "Font")
1919        .map(|(_, v)| v.clone());
1920    let font_obj = match font_obj {
1921        Some(Object::Reference(id)) => reader.resolve(id)?,
1922        Some(other) => other,
1923        None => return Ok(None),
1924    };
1925    let Object::Dict(font_dict) = font_obj else {
1926        return Ok(None);
1927    };
1928    let mut out: BTreeMap<String, Type3Font> = BTreeMap::new();
1929    for (name, value) in font_dict.entries() {
1930        let resolved = match value {
1931            Object::Reference(id) => reader.resolve(*id)?,
1932            other => other.clone(),
1933        };
1934        let Object::Dict(fd) = resolved else {
1935            continue;
1936        };
1937        if !matches!(
1938            fd.entries().iter().find(|(k, _)| k == "Subtype"),
1939            Some((_, Object::Name(s))) if s == "Type3"
1940        ) {
1941            continue;
1942        }
1943        if let Some(font) = resolve_one_type3_font(reader, &fd, resources, depth)? {
1944            out.insert(name.clone(), font);
1945        }
1946    }
1947    if out.is_empty() {
1948        Ok(None)
1949    } else {
1950        Ok(Some(out))
1951    }
1952}
1953
1954/// Parse a single Type 3 font dictionary into a [`Type3Font`] (§9.6.5).
1955/// Returns `Ok(None)` when the font has no paintable glyphs.
1956///
1957/// `enclosing_resources` is the resource dictionary the font was found
1958/// in (the page's, a Form XObject's, or a pattern cell's). Per §9.6.5
1959/// Table 112, when a glyph description names resources but the font
1960/// carries no `/Resources` of its own, the names resolve against this
1961/// enclosing dictionary.
1962fn resolve_one_type3_font(
1963    reader: &mut DocumentReader<'_>,
1964    fd: &Dict,
1965    enclosing_resources: &Dict,
1966    depth: usize,
1967) -> Result<Option<Type3Font>, PdfError> {
1968    // /FontMatrix (Table 112, required) — default to the conventional
1969    // 1000-unit glyph space when absent / malformed.
1970    let font_matrix = match fd.entries().iter().find(|(k, _)| k == "FontMatrix") {
1971        Some((_, obj @ Object::Array(items))) if items.len() == 6 => array_matrix(obj),
1972        _ => Transform2D {
1973            a: 0.001,
1974            b: 0.0,
1975            c: 0.0,
1976            d: 0.001,
1977            e: 0.0,
1978            f: 0.0,
1979        },
1980    };
1981
1982    // /Encoding /Differences → code → glyph name (§9.6.6.1). A Type 3
1983    // font's complete encoding lives in /Differences (Table 112).
1984    let mut encoding: BTreeMap<u8, String> = BTreeMap::new();
1985    let enc_obj = match fd.entries().iter().find(|(k, _)| k == "Encoding") {
1986        Some((_, Object::Reference(id))) => Some(reader.resolve(*id)?),
1987        Some((_, other)) => Some(other.clone()),
1988        None => None,
1989    };
1990    if let Some(Object::Dict(enc)) = enc_obj {
1991        let diffs = match enc.entries().iter().find(|(k, _)| k == "Differences") {
1992            Some((_, Object::Reference(id))) => Some(reader.resolve(*id)?),
1993            Some((_, other)) => Some(other.clone()),
1994            None => None,
1995        };
1996        if let Some(arr) = diffs {
1997            if let Ok(parsed) = crate::reader::encoding::parse_encoding_differences(&arr) {
1998                for ov in parsed.overrides {
1999                    encoding.insert(ov.code, ov.glyph_name);
2000                }
2001            }
2002        }
2003    }
2004    if encoding.is_empty() {
2005        return Ok(None);
2006    }
2007
2008    // The font's own /Resources (Table 112). A glyph description that
2009    // names a resource looks it up here; when the font omits /Resources
2010    // the names fall back to the enclosing (page / form / cell) resource
2011    // dictionary the font was found in (§9.6.5 Table 112).
2012    let glyph_resources = match fd.entries().iter().find(|(k, _)| k == "Resources") {
2013        Some((_, Object::Reference(id))) => match reader.resolve(*id)? {
2014            Object::Dict(d) => Some(d),
2015            _ => Some(enclosing_resources.clone()),
2016        },
2017        Some((_, Object::Dict(d))) => Some(d.clone()),
2018        _ => Some(enclosing_resources.clone()),
2019    };
2020
2021    // /CharProcs — glyph name → glyph-description stream (Table 112).
2022    let charprocs_obj = match fd.entries().iter().find(|(k, _)| k == "CharProcs") {
2023        Some((_, Object::Reference(id))) => reader.resolve(*id)?,
2024        Some((_, other)) => other.clone(),
2025        None => return Ok(None),
2026    };
2027    let Object::Dict(charprocs) = charprocs_obj else {
2028        return Ok(None);
2029    };
2030
2031    let mut glyphs: BTreeMap<String, Group> = BTreeMap::new();
2032    let mut shape_only: BTreeSet<String> = BTreeSet::new();
2033    // Only resolve glyphs the encoding actually references.
2034    let referenced: BTreeSet<&String> = encoding.values().collect();
2035    for (glyph_name, value) in charprocs.entries() {
2036        if !referenced.contains(glyph_name) {
2037            continue;
2038        }
2039        let resolved = match value {
2040            Object::Reference(id) => reader.resolve(*id)?,
2041            other => other.clone(),
2042        };
2043        let Object::Stream(stream) = resolved else {
2044            continue;
2045        };
2046        let content_bytes = match decode_stream(&stream) {
2047            Ok(b) => b,
2048            Err(_) => continue,
2049        };
2050        // Detect a leading `d1` (shape-only glyph, §9.6.5 Table 113) so
2051        // the walker can later recolour it with the current fill colour.
2052        if charproc_is_shape_only(&content_bytes) {
2053            shape_only.insert(glyph_name.clone());
2054        }
2055        if let Some(group) =
2056            parse_glyph_description(reader, &content_bytes, glyph_resources.as_ref(), depth)?
2057        {
2058            if !group.children.is_empty() {
2059                glyphs.insert(glyph_name.clone(), group);
2060            }
2061        }
2062    }
2063    if glyphs.is_empty() {
2064        return Ok(None);
2065    }
2066    Ok(Some(Type3Font {
2067        font_matrix,
2068        encoding,
2069        glyphs,
2070        shape_only,
2071    }))
2072}
2073
2074/// Whether a Type 3 glyph description's first operator is `d1` (§9.6.5
2075/// Table 113) — meaning the glyph specifies shape only and takes its
2076/// colour from the graphics state. Scans past the leading numeric
2077/// operands to the first keyword token. A `d0` first operator (or
2078/// neither) means the glyph carries its own colour.
2079fn charproc_is_shape_only(bytes: &[u8]) -> bool {
2080    let mut i = 0;
2081    while i < bytes.len() {
2082        let c = bytes[i];
2083        // Skip whitespace + numeric-operand characters (digits, sign,
2084        // dot, exponent) — d0/d1 are preceded by 2 or 6 numbers.
2085        if c.is_ascii_whitespace()
2086            || c.is_ascii_digit()
2087            || c == b'+'
2088            || c == b'-'
2089            || c == b'.'
2090            || c == b'e'
2091            || c == b'E'
2092        {
2093            i += 1;
2094            continue;
2095        }
2096        // First non-numeric token: must be `d0` or `d1`.
2097        if bytes[i..].starts_with(b"d1") {
2098            return true;
2099        }
2100        return false;
2101    }
2102    false
2103}
2104
2105/// Parse a Type 3 glyph description content stream into a [`Group`]
2106/// (§9.6.5). The stream is parsed against the font's own `/Resources`
2107/// (`glyph_resources`); the `d0` / `d1` leading operator is consumed as
2108/// a no-op by the content walker. Returns `Ok(None)` when the content
2109/// parses to nothing.
2110fn parse_glyph_description(
2111    reader: &mut DocumentReader<'_>,
2112    content_bytes: &[u8],
2113    glyph_resources: Option<&Dict>,
2114    depth: usize,
2115) -> Result<Option<Group>, PdfError> {
2116    let ext_gstate_dict = match glyph_resources {
2117        Some(r) => resolve_ext_gstate(reader, r)?,
2118        None => None,
2119    };
2120    let fonts_dict = match glyph_resources {
2121        Some(r) => resolve_font_resources(reader, r)?,
2122        None => None,
2123    };
2124    let shading_dict = match glyph_resources {
2125        Some(r) => resolve_shading_resources(reader, r)?,
2126        None => None,
2127    };
2128    let color_space_dict = match glyph_resources {
2129        Some(r) => resolve_color_space_resources(reader, r)?,
2130        None => None,
2131    };
2132    let properties_dict = match glyph_resources {
2133        Some(r) => resolve_properties_resources(reader, r)?,
2134        None => None,
2135    };
2136    let mut seen = HashSet::new();
2137    let nested_forms = match glyph_resources {
2138        Some(r) => resolve_xobject_forms(reader, r, depth + 1, &mut seen)?,
2139        None => None,
2140    };
2141    let pattern_dict = match glyph_resources {
2142        Some(r) => resolve_pattern_resources(reader, r)?,
2143        None => None,
2144    };
2145    let tiling_patterns = match glyph_resources {
2146        Some(r) => resolve_tiling_patterns(reader, r, depth + 1)?,
2147        None => None,
2148    };
2149    // A glyph description may itself show text in a (nested) Type 3
2150    // font; recurse with a deeper bound so a self-referential glyph
2151    // terminates.
2152    let nested_type3 = match glyph_resources {
2153        Some(r) => resolve_type3_fonts(reader, r, depth + 1)?,
2154        None => None,
2155    };
2156
2157    let parsed = parse_content_stream_full_with_type3(
2158        content_bytes,
2159        ext_gstate_dict.as_ref(),
2160        fonts_dict.as_ref(),
2161        shading_dict.as_ref(),
2162        color_space_dict.as_ref(),
2163        properties_dict.as_ref(),
2164        nested_forms.as_ref(),
2165        pattern_dict.as_ref(),
2166        tiling_patterns.as_ref(),
2167        nested_type3.as_ref(),
2168    )?;
2169    if parsed.root.children.is_empty() {
2170        return Ok(None);
2171    }
2172    Ok(Some(parsed.root))
2173}
2174
2175/// A six-number `/FontMatrix` / `/Matrix` array `Object` as a
2176/// [`Transform2D`]. Caller guarantees the array has six elements.
2177fn array_matrix(obj: &Object) -> Transform2D {
2178    let Object::Array(items) = obj else {
2179        return Transform2D::identity();
2180    };
2181    let mut m = [0.0f32; 6];
2182    for (i, slot) in m.iter_mut().enumerate() {
2183        match items.get(i) {
2184            Some(Object::Integer(v)) => *slot = *v as f32,
2185            Some(Object::Real(v)) => *slot = *v as f32,
2186            _ => return Transform2D::identity(),
2187        }
2188    }
2189    Transform2D {
2190        a: m[0],
2191        b: m[1],
2192        c: m[2],
2193        d: m[3],
2194        e: m[4],
2195        f: m[5],
2196    }
2197}
2198
2199/// Read a dict entry as an `f32` (Integer or Real). `None` for any other
2200/// shape or an absent key.
2201fn dict_num(dict: &Dict, key: &str) -> Option<f32> {
2202    match dict
2203        .entries()
2204        .iter()
2205        .find(|(k, _)| k == key)
2206        .map(|(_, v)| v)
2207    {
2208        Some(Object::Integer(v)) => Some(*v as f32),
2209        Some(Object::Real(v)) => Some(*v as f32),
2210        _ => None,
2211    }
2212}
2213
2214/// Read a dict entry as an `i64` (Integer). `None` for any other shape.
2215fn dict_int(dict: &Dict, key: &str) -> Option<i64> {
2216    match dict
2217        .entries()
2218        .iter()
2219        .find(|(k, _)| k == key)
2220        .map(|(_, v)| v)
2221    {
2222        Some(Object::Integer(v)) => Some(*v),
2223        _ => None,
2224    }
2225}
2226
2227/// Read a four-number rectangle entry `[a b c d]` as `[a, b, c, d]`.
2228/// `None` when absent, not a 4-element array, or any element is not a
2229/// finite number.
2230fn read_rect(dict: &Dict, key: &str) -> Option<[f32; 4]> {
2231    let items = match dict
2232        .entries()
2233        .iter()
2234        .find(|(k, _)| k == key)
2235        .map(|(_, v)| v)
2236    {
2237        Some(Object::Array(items)) if items.len() == 4 => items,
2238        _ => return None,
2239    };
2240    let mut out = [0.0f32; 4];
2241    for (i, slot) in out.iter_mut().enumerate() {
2242        *slot = match &items[i] {
2243            Object::Integer(v) => *v as f32,
2244            Object::Real(v) => *v as f32,
2245            _ => return None,
2246        };
2247        if !slot.is_finite() {
2248            return None;
2249        }
2250    }
2251    Some(out)
2252}
2253
2254/// Resolve a page's `/Resources /ColorSpace` subdictionary into a
2255/// fully-dereferenced [`Dict`] whose per-name entries are resolved
2256/// colour-space `Object`s the round-275 content parser interprets
2257/// (ISO 32000-1 §8.6.8 Table 74 + §8.6.5 + §8.6.6).
2258///
2259/// Returns `Ok(None)` when the resources dict carries no `/ColorSpace`
2260/// entry — the common case for documents that paint only in the
2261/// implicit device families (`rg` / `g` / `k`) or name the device
2262/// families directly in `cs` / `CS`.
2263///
2264/// Each per-name value is resolved so the parser never has to touch
2265/// the reader:
2266///
2267/// * A bare device `/Name` passes through verbatim.
2268/// * An `[ /ICCBased <stream-ref> ]` array (§8.6.5.5) has the ICC
2269///   profile stream replaced by its **dictionary** — the parser reads
2270///   `/N` + `/Alternate` from it to pick the device fallback; the ICC
2271///   profile bytes are never interpreted, so they are dropped.
2272/// * An `[ /Indexed base hival lookup ]` array (§8.6.6.3) has a lookup
2273///   *stream* (PDF 1.2 allows a stream or a byte string) replaced by
2274///   its decoded bytes as a `HexString` so the parser sees a
2275///   self-contained colour table; a base that is itself an indirect
2276///   reference is dereferenced one hop.
2277///
2278/// Any other shape (CalRGB / CalGray / Lab / Separation / DeviceN /
2279/// Pattern, or a malformed entry) is surfaced verbatim — the parser's
2280/// [`crate::reader::content`] interpreter reduces what it can and
2281/// leaves the rest `Unknown` (the round-118 black fallback).
2282fn resolve_color_space_resources(
2283    reader: &mut DocumentReader<'_>,
2284    resources: &Dict,
2285) -> Result<Option<Dict>, PdfError> {
2286    let cs_obj = resources
2287        .entries()
2288        .iter()
2289        .find(|(k, _)| k == "ColorSpace")
2290        .map(|(_, v)| v.clone());
2291    let cs_obj = match cs_obj {
2292        Some(Object::Reference(id)) => reader.resolve(id)?,
2293        Some(other) => other,
2294        None => return Ok(None),
2295    };
2296    let Object::Dict(cs_dict) = cs_obj else {
2297        return Ok(None);
2298    };
2299    let mut out = Dict::new();
2300    for (name, value) in cs_dict.entries() {
2301        let resolved = match value {
2302            Object::Reference(id) => reader.resolve(*id)?,
2303            other => other.clone(),
2304        };
2305        let prepared = prepare_color_space_object(reader, resolved)?;
2306        out.set(name, prepared);
2307    }
2308    Ok(Some(out))
2309}
2310
2311/// Resolve a page's `/Resources /Properties` subdictionary into a
2312/// fully-dereferenced [`Dict`] (each per-name property-list value is
2313/// itself resolved into a direct `Object::Dict` if it was an indirect
2314/// reference). Returns `Ok(None)` when the resources dict carries no
2315/// `/Properties` entry — the common case for documents that use no
2316/// `DP`/`BDC` marked-content operator, or that only ever write their
2317/// property lists inline (§14.6.2).
2318///
2319/// Mirrors [`resolve_ext_gstate`] / [`resolve_font_resources`] /
2320/// [`resolve_shading_resources`] — single-hop indirect dereference,
2321/// malformed entries silently dropped so a `DP`/`BDC` naming the
2322/// missing key still emits its event with `properties = None` (ISO
2323/// 32000-1 §14.6.2 + §7.8.3 Table 33 for the `/Resources /Properties`
2324/// shape).
2325fn resolve_properties_resources(
2326    reader: &mut DocumentReader<'_>,
2327    resources: &Dict,
2328) -> Result<Option<Dict>, PdfError> {
2329    let props_obj = resources
2330        .entries()
2331        .iter()
2332        .find(|(k, _)| k == "Properties")
2333        .map(|(_, v)| v.clone());
2334    let props_obj = match props_obj {
2335        Some(Object::Reference(id)) => reader.resolve(id)?,
2336        Some(other) => other,
2337        None => return Ok(None),
2338    };
2339    let Object::Dict(props_dict) = props_obj else {
2340        return Ok(None);
2341    };
2342    let mut out = Dict::new();
2343    for (name, value) in props_dict.entries() {
2344        let resolved = match value {
2345            Object::Reference(id) => reader.resolve(*id)?,
2346            other => other.clone(),
2347        };
2348        // A property list may also be carried as a stream object (e.g.
2349        // an /OCG membership dict referenced indirectly is a dict, but
2350        // some producers wrap larger lists in streams). Surface either
2351        // shape as the per-name entry's resolved `Dict`.
2352        let d = match resolved {
2353            Object::Dict(d) => Some(d),
2354            Object::Stream(s) => Some(s.dict),
2355            _ => None,
2356        };
2357        if let Some(d) = d {
2358            out.set(name, Object::Dict(d));
2359        }
2360    }
2361    Ok(Some(out))
2362}
2363
2364/// Maximum nesting depth for Form XObject recursion (§8.10). A form
2365/// may paint another form via its own `Do`; without a ceiling a
2366/// pathologically deep (or cyclic, though the visited-set guards the
2367/// direct cycle) chain could exhaust the stack. 12 mirrors the
2368/// parser's own structural depth ceiling and is far beyond any
2369/// legitimate document's appearance-stream nesting.
2370const MAX_XOBJECT_DEPTH: usize = 12;
2371
2372/// Resolve a page's (or form's) `/Resources /XObject` subdictionary
2373/// into a map of resource-name → pre-parsed Form XObject [`Group`]
2374/// (ISO 32000-1 §8.10). Image XObjects are skipped (they are surfaced
2375/// separately by [`crate::reader::images`]); only `/Subtype /Form`
2376/// entries are returned.
2377///
2378/// Each form's content stream is decoded, its own `/Resources` are
2379/// resolved, and its content is recursively parsed (so a form that
2380/// itself paints nested forms via `Do` is expanded). The resulting
2381/// `Group` carries:
2382///
2383/// * `transform` = the form's `/Matrix` (default identity), mapping
2384///   form space into the user space in effect where `Do` is invoked
2385///   (§8.10.1: the matrix is concatenated with the CTM);
2386/// * `clip` = the `/BBox` rectangle as a closed subpath (§8.10.1: the
2387///   form is clipped to its bounding box).
2388///
2389/// `depth` bounds the recursion at [`MAX_XOBJECT_DEPTH`]; `visited`
2390/// tracks the object ids of forms currently on the resolution stack so
2391/// a direct self-reference (a form whose content `Do`s itself) is
2392/// broken rather than looping. A malformed or unresolvable entry is
2393/// silently dropped so a `Do` against it behaves as a tolerated no-op.
2394///
2395/// Returns `Ok(None)` when the resources dict carries no `/XObject`
2396/// entry or when no entry resolved to a non-empty Form group.
2397fn resolve_xobject_forms(
2398    reader: &mut DocumentReader<'_>,
2399    resources: &Dict,
2400    depth: usize,
2401    visited: &mut HashSet<ObjectId>,
2402) -> Result<Option<BTreeMap<String, Group>>, PdfError> {
2403    if depth >= MAX_XOBJECT_DEPTH {
2404        return Ok(None);
2405    }
2406    let xobj_obj = resources
2407        .entries()
2408        .iter()
2409        .find(|(k, _)| k == "XObject")
2410        .map(|(_, v)| v.clone());
2411    let xobj_obj = match xobj_obj {
2412        Some(Object::Reference(id)) => reader.resolve(id)?,
2413        Some(other) => other,
2414        None => return Ok(None),
2415    };
2416    let Object::Dict(xobj_dict) = xobj_obj else {
2417        return Ok(None);
2418    };
2419    let mut out: BTreeMap<String, Group> = BTreeMap::new();
2420    for (name, value) in xobj_dict.entries() {
2421        // Per §8.9 / §8.10 an XObject is an indirect object; capture
2422        // its id so a form referencing itself is cycle-guarded.
2423        let (form_id, resolved) = match value {
2424            Object::Reference(id) => (Some(*id), reader.resolve(*id)?),
2425            other => (None, other.clone()),
2426        };
2427        let Object::Stream(stream) = resolved else {
2428            continue;
2429        };
2430        // Only Form XObjects splice into the scene tree here.
2431        let subtype = stream
2432            .dict
2433            .entries()
2434            .iter()
2435            .find(|(k, _)| k == "Subtype")
2436            .map(|(_, v)| v);
2437        if !matches!(subtype, Some(Object::Name(s)) if s == "Form") {
2438            continue;
2439        }
2440        if let Some(id) = form_id {
2441            if visited.contains(&id) {
2442                continue;
2443            }
2444            visited.insert(id);
2445        }
2446        let group = resolve_one_form_xobject(reader, &stream, depth, visited)?;
2447        if let Some(id) = form_id {
2448            visited.remove(&id);
2449        }
2450        if let Some(g) = group {
2451            if !g.children.is_empty() {
2452                out.insert(name.clone(), g);
2453            }
2454        }
2455    }
2456    if out.is_empty() {
2457        Ok(None)
2458    } else {
2459        Ok(Some(out))
2460    }
2461}
2462
2463/// Parse one Form XObject stream into a [`Group`] (§8.10.1). The
2464/// form's `/Matrix` becomes the group transform, its `/BBox` the group
2465/// clip, and its content stream — resolved against its own
2466/// `/Resources` (fonts, ext-gstate, shadings, colour spaces,
2467/// properties, and nested Form XObjects) — the group children.
2468/// Returns `Ok(None)` for a form that can't be decoded or whose
2469/// content parses to nothing.
2470fn resolve_one_form_xobject(
2471    reader: &mut DocumentReader<'_>,
2472    stream: &Stream,
2473    depth: usize,
2474    visited: &mut HashSet<ObjectId>,
2475) -> Result<Option<Group>, PdfError> {
2476    let content_bytes = match decode_stream(stream) {
2477        Ok(b) => b,
2478        Err(_) => return Ok(None),
2479    };
2480
2481    // The form's own /Resources (Table 95). A form may omit it (PDF 1.1
2482    // promoted resources to the page), in which case the form sees no
2483    // resources here — its `Do` / text / shading operators degrade to
2484    // the same tolerated no-ops the page path uses for a missing entry.
2485    let form_resources = match stream.dict.entries().iter().find(|(k, _)| k == "Resources") {
2486        Some((_, Object::Reference(id))) => match reader.resolve(*id)? {
2487            Object::Dict(d) => Some(d),
2488            _ => None,
2489        },
2490        Some((_, Object::Dict(d))) => Some(d.clone()),
2491        _ => None,
2492    };
2493
2494    let ext_gstate_dict = match form_resources.as_ref() {
2495        Some(r) => resolve_ext_gstate(reader, r)?,
2496        None => None,
2497    };
2498    let fonts_dict = match form_resources.as_ref() {
2499        Some(r) => resolve_font_resources(reader, r)?,
2500        None => None,
2501    };
2502    let shading_dict = match form_resources.as_ref() {
2503        Some(r) => resolve_shading_resources(reader, r)?,
2504        None => None,
2505    };
2506    let color_space_dict = match form_resources.as_ref() {
2507        Some(r) => resolve_color_space_resources(reader, r)?,
2508        None => None,
2509    };
2510    let properties_dict = match form_resources.as_ref() {
2511        Some(r) => resolve_properties_resources(reader, r)?,
2512        None => None,
2513    };
2514    let nested_forms = match form_resources.as_ref() {
2515        Some(r) => resolve_xobject_forms(reader, r, depth + 1, visited)?,
2516        None => None,
2517    };
2518    let pattern_dict = match form_resources.as_ref() {
2519        Some(r) => resolve_pattern_resources(reader, r)?,
2520        None => None,
2521    };
2522    let tiling_patterns = match form_resources.as_ref() {
2523        Some(r) => resolve_tiling_patterns(reader, r, depth + 1)?,
2524        None => None,
2525    };
2526    let type3_fonts = match form_resources.as_ref() {
2527        Some(r) => resolve_type3_fonts(reader, r, depth + 1)?,
2528        None => None,
2529    };
2530
2531    let parsed = parse_content_stream_full_with_type3(
2532        &content_bytes,
2533        ext_gstate_dict.as_ref(),
2534        fonts_dict.as_ref(),
2535        shading_dict.as_ref(),
2536        color_space_dict.as_ref(),
2537        properties_dict.as_ref(),
2538        nested_forms.as_ref(),
2539        pattern_dict.as_ref(),
2540        tiling_patterns.as_ref(),
2541        type3_fonts.as_ref(),
2542    )?;
2543
2544    // The content parser returns a root `Group` carrying any top-level
2545    // `cm` transform + clip. We nest that under an outer group whose
2546    // transform is the form's /Matrix and whose clip is the /BBox, so
2547    // the §8.10.1 (b) concat-Matrix and (c) clip-BBox steps wrap the
2548    // form's own content as a single splice-able unit.
2549    let inner = parsed.root;
2550    let matrix = form_matrix(&stream.dict);
2551    let clip = form_bbox_clip(&stream.dict);
2552    let children = if inner.transform == Transform2D::identity()
2553        && inner.clip.is_none()
2554        && inner.opacity == 1.0
2555    {
2556        // The inner root is a bare container — flatten it so we don't
2557        // wrap an identity group inside the form group.
2558        inner.children
2559    } else {
2560        vec![Node::Group(inner)]
2561    };
2562    if children.is_empty() {
2563        return Ok(None);
2564    }
2565    Ok(Some(Group {
2566        transform: matrix,
2567        opacity: 1.0,
2568        clip,
2569        children,
2570        ..Group::default()
2571    }))
2572}
2573
2574/// The form's `/Matrix` (§8.10.2 Table 95) as a [`Transform2D`], or
2575/// the identity matrix when absent / malformed.
2576fn form_matrix(dict: &Dict) -> Transform2D {
2577    let nums = match dict.entries().iter().find(|(k, _)| k == "Matrix") {
2578        Some((_, Object::Array(items))) if items.len() == 6 => items,
2579        _ => return Transform2D::identity(),
2580    };
2581    let mut m = [0.0f32; 6];
2582    for (i, slot) in m.iter_mut().enumerate() {
2583        match &nums[i] {
2584            Object::Integer(v) => *slot = *v as f32,
2585            Object::Real(v) => *slot = *v as f32,
2586            _ => return Transform2D::identity(),
2587        }
2588    }
2589    Transform2D {
2590        a: m[0],
2591        b: m[1],
2592        c: m[2],
2593        d: m[3],
2594        e: m[4],
2595        f: m[5],
2596    }
2597}
2598
2599/// The form's `/BBox` (§8.10.2 Table 95) as a closed-rectangle clip
2600/// [`Path`], or `None` when absent / malformed. The four numbers are
2601/// the left, bottom, right, top edges in form space (the same
2602/// coordinate system the group transform — the form `/Matrix` — is
2603/// applied in, so the clip is expressed pre-transform exactly like the
2604/// content it bounds).
2605fn form_bbox_clip(dict: &Dict) -> Option<Path> {
2606    let items = match dict.entries().iter().find(|(k, _)| k == "BBox") {
2607        Some((_, Object::Array(items))) if items.len() == 4 => items,
2608        _ => return None,
2609    };
2610    let mut v = [0.0f32; 4];
2611    for (i, slot) in v.iter_mut().enumerate() {
2612        match &items[i] {
2613            Object::Integer(n) => *slot = *n as f32,
2614            Object::Real(n) => *slot = *n as f32,
2615            _ => return None,
2616        }
2617    }
2618    let (x0, y0, x1, y1) = (v[0], v[1], v[2], v[3]);
2619    // Normalise so the rectangle is well-formed regardless of edge
2620    // ordering (§8.10.2 names them left/bottom/right/top but a
2621    // producer may emit them swapped).
2622    let (lx, rx) = (x0.min(x1), x0.max(x1));
2623    let (by, ty) = (y0.min(y1), y0.max(y1));
2624    if rx <= lx || ty <= by {
2625        return None;
2626    }
2627    let mut path = Path::new();
2628    path.commands.push(PathCommand::MoveTo(Point::new(lx, by)));
2629    path.commands.push(PathCommand::LineTo(Point::new(rx, by)));
2630    path.commands.push(PathCommand::LineTo(Point::new(rx, ty)));
2631    path.commands.push(PathCommand::LineTo(Point::new(lx, ty)));
2632    path.commands.push(PathCommand::Close);
2633    Some(path)
2634}
2635
2636/// A dictionary entry's 4-number rectangle, normalised so element 0/1
2637/// is the lower-left corner and 2/3 the upper-right (§7.9.5 — "the
2638/// form of a rectangle is not required to place the smaller values
2639/// first"; consumers shall normalise). Returns `None` when the entry
2640/// is absent, not a 4-array, or carries a non-numeric element.
2641fn dict_rect4(dict: &Dict, key: &str) -> Option<[f32; 4]> {
2642    let items = match dict.entries().iter().find(|(k, _)| k == key) {
2643        Some((_, Object::Array(items))) if items.len() == 4 => items,
2644        _ => return None,
2645    };
2646    let mut v = [0.0f32; 4];
2647    for (i, slot) in v.iter_mut().enumerate() {
2648        match &items[i] {
2649            Object::Integer(n) => *slot = *n as f32,
2650            Object::Real(n) => *slot = *n as f32,
2651            _ => return None,
2652        }
2653    }
2654    Some([
2655        v[0].min(v[2]),
2656        v[1].min(v[3]),
2657        v[0].max(v[2]),
2658        v[1].max(v[3]),
2659    ])
2660}
2661
2662/// §12.5.5 — resolve every annotation in the page's `/Annots` array
2663/// (§12.5.2 Table 164) whose appearance dictionary carries an
2664/// applicable appearance stream, and return one positioned [`Group`]
2665/// per painted annotation, in `/Annots` array order.
2666///
2667/// Enumeration is best-effort like the `annotations()` surface: a
2668/// malformed annotation dictionary (or one whose appearance stream
2669/// can't be decoded) contributes nothing rather than aborting the
2670/// page.
2671fn resolve_annotation_appearances(
2672    reader: &mut DocumentReader<'_>,
2673    page_dict: &Dict,
2674    optional_content: Option<&crate::reader::ocg::OptionalContent>,
2675) -> Result<Vec<Group>, PdfError> {
2676    let annots = match page_dict
2677        .entries()
2678        .iter()
2679        .find(|(k, _)| k == "Annots")
2680        .map(|(_, v)| v.clone())
2681    {
2682        Some(Object::Reference(id)) => match reader.resolve(id) {
2683            Ok(o) => o,
2684            Err(_) => return Ok(Vec::new()),
2685        },
2686        Some(other) => other,
2687        None => return Ok(Vec::new()),
2688    };
2689    let Object::Array(items) = annots else {
2690        return Ok(Vec::new());
2691    };
2692    let mut out = Vec::new();
2693    for item in items {
2694        let annot = match item {
2695            Object::Reference(id) => match reader.resolve(id) {
2696                Ok(o) => o,
2697                Err(_) => continue,
2698            },
2699            other => other,
2700        };
2701        let Object::Dict(annot) = annot else {
2702            continue;
2703        };
2704        // /OC (Table 164) — "Before the annotation is drawn, its
2705        // visibility shall be determined based on this entry"; an
2706        // invisible annotation "shall be skipped, as if it were not
2707        // in the document" (§12.5.2).
2708        if !annotation_oc_visible(reader, &annot, optional_content) {
2709            continue;
2710        }
2711        if let Some(group) = annotation_appearance_group(reader, &annot)? {
2712            out.push(group);
2713        }
2714    }
2715    Ok(out)
2716}
2717
2718/// §12.5.2 Table 164 `/OC` — resolve the annotation's optional-content
2719/// visibility under the document's default configuration (§8.11). The
2720/// entry may reference an optional-content *group* (visible iff the
2721/// group's resolved state is ON) or an optional-content *membership*
2722/// dictionary (`/Type /OCMD`, evaluated through its `/P` policy or
2723/// `/VE` visibility expression per §8.11.2.2).
2724///
2725/// Tolerant defaults: no `/OC` entry, no `/OCProperties` in the
2726/// catalog (the document isn't layered), or an unresolvable entry all
2727/// mean *visible*. An OCG referenced by id but absent from
2728/// `/OCProperties /OCGs` is treated as hidden (matching
2729/// [`crate::reader::ocg::OptionalContent::is_visible`]).
2730fn annotation_oc_visible(
2731    reader: &mut DocumentReader<'_>,
2732    annot: &Dict,
2733    optional_content: Option<&crate::reader::ocg::OptionalContent>,
2734) -> bool {
2735    let Some(entry) = annot
2736        .entries()
2737        .iter()
2738        .find(|(k, _)| k == "OC")
2739        .map(|(_, v)| v.clone())
2740    else {
2741        return true;
2742    };
2743    let Some(oc) = optional_content else {
2744        return true;
2745    };
2746    let (group_id, dict) = match entry {
2747        Object::Reference(id) => match reader.resolve(id) {
2748            Ok(Object::Dict(d)) => (Some(id), d),
2749            _ => return true,
2750        },
2751        Object::Dict(d) => (None, d),
2752        _ => return true,
2753    };
2754    // OCMD when tagged /Type /OCMD or carrying the OCMD-only keys;
2755    // otherwise it's an OCG whose id looks up the resolved state.
2756    let type_name = dict.entries().iter().find_map(|(k, v)| match (k, v) {
2757        (k, Object::Name(n)) if k == "Type" => Some(n.clone()),
2758        _ => None,
2759    });
2760    let is_ocmd = type_name.as_deref() == Some("OCMD")
2761        || (type_name.is_none() && dict.entries().iter().any(|(k, _)| k == "OCGs" || k == "VE"));
2762    if is_ocmd {
2763        match crate::reader::ocg::parse_membership(reader, &dict) {
2764            Ok(Some(mem)) => oc.evaluate_membership(&mem),
2765            _ => true,
2766        }
2767    } else {
2768        match group_id {
2769            Some(id) => oc.is_visible(id),
2770            // An inline (non-indirect) OCG can't be matched against
2771            // /OCProperties /OCGs — tolerate as visible.
2772            None => true,
2773        }
2774    }
2775}
2776
2777/// §12.5.5 — resolve one annotation's normal (`/AP /N`) appearance
2778/// stream into a [`Group`] positioned inside the annotation `/Rect`.
2779///
2780/// The appearance stream is a Form XObject (§8.10): its content is
2781/// parsed by [`resolve_one_form_xobject`] into a group carrying the
2782/// form `/Matrix` as transform and `/BBox` as clip. The group is then
2783/// wrapped in the §12.5.5 *Algorithm: Appearance streams* placement
2784/// matrix `A`:
2785///
2786///   a) the `/BBox` corners are transformed by `/Matrix` and the
2787///      smallest upright rectangle enclosing the quadrilateral taken
2788///      (the *transformed appearance box*);
2789///   b) `A` scales + translates that box onto the annotation `/Rect`
2790///      (lower-left corner to lower-left corner, upper-right to
2791///      upper-right);
2792///   c) the effective content mapping is `AA = Matrix × A` — realised
2793///      here as the outer wrapper carrying `A` and the inner form
2794///      group carrying `Matrix`.
2795///
2796/// Returns `Ok(None)` for an annotation without an applicable
2797/// appearance (no `/AP`, no usable `/N` stream, missing `/Rect` or
2798/// `/BBox`, or content that parses to nothing) — NOTE 3's "reasonable
2799/// behaviour (such as displaying nothing)".
2800fn annotation_appearance_group(
2801    reader: &mut DocumentReader<'_>,
2802    annot: &Dict,
2803) -> Result<Option<Group>, PdfError> {
2804    // /F (Table 164) — the §12.5.3 flag word. A Hidden (bit 2)
2805    // annotation "shall not be displayed or printed … regardless of
2806    // its annotation type"; a NoView (bit 6) annotation is hidden for
2807    // on-screen display (Table 165). Neither reaches the scene.
2808    let flags = match annot.entries().iter().find(|(k, _)| k == "F") {
2809        Some((_, Object::Integer(v))) => *v,
2810        _ => 0,
2811    };
2812    const FLAG_HIDDEN: i64 = 1 << 1; // bit 2
2813    const FLAG_NO_VIEW: i64 = 1 << 5; // bit 6
2814    if flags & (FLAG_HIDDEN | FLAG_NO_VIEW) != 0 {
2815        return Ok(None);
2816    }
2817
2818    // §12.5.6.14 — a pop-up annotation "shall have no appearance
2819    // stream … of its own"; its text is displayed through the pop-up
2820    // window machinery, not painted on the page. Skip the subtype
2821    // outright.
2822    if matches!(
2823        annot.entries().iter().find(|(k, _)| k == "Subtype"),
2824        Some((_, Object::Name(s))) if s == "Popup"
2825    ) {
2826        return Ok(None);
2827    }
2828
2829    // /Rect (Table 164, required) — the annotation rectangle in
2830    // default user space.
2831    let Some(rect) = dict_rect4(annot, "Rect") else {
2832        return Ok(None);
2833    };
2834
2835    // /AP (Table 164) → the Table 168 appearance dictionary.
2836    let ap = match annot
2837        .entries()
2838        .iter()
2839        .find(|(k, _)| k == "AP")
2840        .map(|(_, v)| v.clone())
2841    {
2842        Some(Object::Reference(id)) => match reader.resolve(id) {
2843            Ok(o) => o,
2844            Err(_) => return Ok(None),
2845        },
2846        Some(other) => other,
2847        None => return Ok(None),
2848    };
2849    let Object::Dict(ap) = ap else {
2850        return Ok(None);
2851    };
2852
2853    // /N (Table 168, required) — the normal appearance, used when the
2854    // annotation is not interacting with the user (and for printing).
2855    let n_entry = ap
2856        .entries()
2857        .iter()
2858        .find(|(k, _)| k == "N")
2859        .map(|(_, v)| v.clone());
2860    let (stream_id, n_obj) = match n_entry {
2861        Some(Object::Reference(id)) => match reader.resolve(id) {
2862            Ok(o) => (Some(id), o),
2863            Err(_) => return Ok(None),
2864        },
2865        Some(other) => (None, other),
2866        None => return Ok(None),
2867    };
2868    let (stream_id, stream) = match n_obj {
2869        Object::Stream(s) => (stream_id, s),
2870        // §12.5.5 — an appearance-dictionary entry may instead be a
2871        // subdictionary of appearance streams keyed by appearance
2872        // state; the annotation's /AS entry (Table 164, required in
2873        // that case) selects the applicable one. An absent /AS, or an
2874        // /AS designating a state the subdictionary doesn't define,
2875        // displays nothing (NOTE 3).
2876        Object::Dict(states) => {
2877            let Some(Object::Name(state)) = annot
2878                .entries()
2879                .iter()
2880                .find(|(k, _)| k == "AS")
2881                .map(|(_, v)| v.clone())
2882            else {
2883                return Ok(None);
2884            };
2885            let selected = states
2886                .entries()
2887                .iter()
2888                .find(|(k, _)| *k == state)
2889                .map(|(_, v)| v.clone());
2890            match selected {
2891                Some(Object::Reference(id)) => match reader.resolve(id) {
2892                    Ok(Object::Stream(s)) => (Some(id), s),
2893                    _ => return Ok(None),
2894                },
2895                Some(Object::Stream(s)) => (None, s),
2896                _ => return Ok(None),
2897            }
2898        }
2899        _ => return Ok(None),
2900    };
2901
2902    build_appearance_group(reader, &stream, stream_id, rect)
2903}
2904
2905/// Parse one appearance stream (a Form XObject per §12.5.5) and wrap
2906/// it in the placement matrix `A` mapping its transformed `/BBox` onto
2907/// the annotation rectangle `rect` (already normalised lower-left /
2908/// upper-right).
2909fn build_appearance_group(
2910    reader: &mut DocumentReader<'_>,
2911    stream: &Stream,
2912    stream_id: Option<ObjectId>,
2913    rect: [f32; 4],
2914) -> Result<Option<Group>, PdfError> {
2915    // /BBox (Table 95, required for a form XObject) — an appearance
2916    // without one can't be mapped onto /Rect; display nothing.
2917    let Some(bbox) = dict_rect4(&stream.dict, "BBox") else {
2918        return Ok(None);
2919    };
2920    let matrix = form_matrix(&stream.dict);
2921
2922    let mut visited = HashSet::new();
2923    if let Some(id) = stream_id {
2924        visited.insert(id);
2925    }
2926    let Some(form_group) = resolve_one_form_xobject(reader, stream, 0, &mut visited)? else {
2927        return Ok(None);
2928    };
2929
2930    // Step a) — transform the BBox corners by Matrix and take the
2931    // smallest upright rectangle that encompasses the quadrilateral.
2932    let corners = [
2933        Point::new(bbox[0], bbox[1]),
2934        Point::new(bbox[2], bbox[1]),
2935        Point::new(bbox[2], bbox[3]),
2936        Point::new(bbox[0], bbox[3]),
2937    ];
2938    let (mut tx0, mut ty0, mut tx1, mut ty1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
2939    for c in corners {
2940        let p = matrix.apply(c);
2941        tx0 = tx0.min(p.x);
2942        ty0 = ty0.min(p.y);
2943        tx1 = tx1.max(p.x);
2944        ty1 = ty1.max(p.y);
2945    }
2946
2947    // Step b) — A scales + translates the transformed appearance box
2948    // onto /Rect. A degenerate axis (zero-width / zero-height box, or
2949    // a non-finite product of a malformed matrix) keeps unit scale on
2950    // that axis and aligns the lower-left corners only.
2951    let (tw, th) = (tx1 - tx0, ty1 - ty0);
2952    let sx = if tw.is_finite() && tw > f32::EPSILON {
2953        (rect[2] - rect[0]) / tw
2954    } else {
2955        1.0
2956    };
2957    let sy = if th.is_finite() && th > f32::EPSILON {
2958        (rect[3] - rect[1]) / th
2959    } else {
2960        1.0
2961    };
2962    let a = Transform2D {
2963        a: sx,
2964        b: 0.0,
2965        c: 0.0,
2966        d: sy,
2967        e: rect[0] - sx * tx0,
2968        f: rect[1] - sy * ty0,
2969    };
2970
2971    // Step c) — AA = Matrix × A: the outer wrapper applies A after the
2972    // inner form group's own /Matrix.
2973    Ok(Some(Group {
2974        transform: a,
2975        children: vec![Node::Group(form_group)],
2976        ..Group::default()
2977    }))
2978}
2979
2980/// Recursively dereference + simplify a colour-space `Object` so the
2981/// content parser sees a self-contained value: ICC profile streams
2982/// become their dictionaries, Indexed lookup streams become their
2983/// decoded bytes, and nested base spaces / indirect references are
2984/// resolved one element at a time. Plain values pass through.
2985fn prepare_color_space_object(
2986    reader: &mut DocumentReader<'_>,
2987    obj: Object,
2988) -> Result<Object, PdfError> {
2989    match obj {
2990        Object::Reference(id) => {
2991            let target = reader.resolve(id)?;
2992            prepare_color_space_object(reader, target)
2993        }
2994        Object::Array(items) => {
2995            let family = items.first().and_then(|o| match o {
2996                Object::Name(n) => Some(n.clone()),
2997                _ => None,
2998            });
2999            match family.as_deref() {
3000                Some("ICCBased") => {
3001                    // [ /ICCBased stream ] — replace the profile stream
3002                    // with its dictionary so the parser reads /N +
3003                    // /Alternate. §8.6.5.5.
3004                    let mut out = vec![Object::Name("ICCBased".into())];
3005                    let stream_obj = match items.into_iter().nth(1) {
3006                        Some(Object::Reference(id)) => reader.resolve(id)?,
3007                        Some(other) => other,
3008                        None => return Ok(Object::Array(out)),
3009                    };
3010                    match stream_obj {
3011                        Object::Stream(s) => {
3012                            // /Alternate may itself be an indirect ref
3013                            // or a nested array — prepare it too.
3014                            let mut dict = s.dict;
3015                            if let Some((_, alt)) = dict
3016                                .entries()
3017                                .iter()
3018                                .find(|(k, _)| k == "Alternate")
3019                                .map(|(k, v)| (k.clone(), v.clone()))
3020                            {
3021                                let prepared_alt = prepare_color_space_object(reader, alt)?;
3022                                dict.set("Alternate", prepared_alt);
3023                            }
3024                            out.push(Object::Dict(dict));
3025                        }
3026                        Object::Dict(d) => out.push(Object::Dict(d)),
3027                        _ => {}
3028                    }
3029                    Ok(Object::Array(out))
3030                }
3031                Some("Indexed") => {
3032                    // [ /Indexed base hival lookup ] — §8.6.6.3.
3033                    let mut it = items.into_iter();
3034                    let _family = it.next();
3035                    let base = match it.next() {
3036                        Some(b) => prepare_color_space_object(reader, b)?,
3037                        None => return Ok(Object::Array(vec![Object::Name("Indexed".into())])),
3038                    };
3039                    let hival = it.next().unwrap_or(Object::Null);
3040                    let hival = match hival {
3041                        Object::Reference(id) => reader.resolve(id)?,
3042                        other => other,
3043                    };
3044                    let lookup = match it.next() {
3045                        Some(Object::Reference(id)) => reader.resolve(id)?,
3046                        Some(other) => other,
3047                        None => Object::Null,
3048                    };
3049                    // A lookup stream (PDF 1.2) → decode to a byte
3050                    // string. A literal/hex string passes through.
3051                    let lookup = match lookup {
3052                        Object::Stream(s) => Object::HexString(decode_stream(&s)?),
3053                        other => other,
3054                    };
3055                    Ok(Object::Array(vec![
3056                        Object::Name("Indexed".into()),
3057                        base,
3058                        hival,
3059                        lookup,
3060                    ]))
3061                }
3062                Some("Separation") => {
3063                    // [ /Separation name alternateSpace tintTransform ]
3064                    // — §8.6.6.4. Resolve the colorant name, prepare the
3065                    // alternate space recursively (it may be a device
3066                    // name, an ICCBased/Indexed array, or an indirect
3067                    // ref), and prepare the tint-transform function so
3068                    // the content parser sees a self-contained 4-element
3069                    // array. The function object is normalised by
3070                    // `prepare_function_object` (Type 0/4 streams keep
3071                    // their dictionary; Type 3 sub-functions recurse).
3072                    let mut it = items.into_iter();
3073                    let _family = it.next();
3074                    let name = match it.next() {
3075                        Some(Object::Reference(id)) => reader.resolve(id)?,
3076                        Some(other) => other,
3077                        None => return Ok(Object::Array(vec![Object::Name("Separation".into())])),
3078                    };
3079                    let alt = match it.next() {
3080                        Some(a) => prepare_color_space_object(reader, a)?,
3081                        None => Object::Null,
3082                    };
3083                    let tint = match it.next() {
3084                        Some(f) => prepare_function_object(reader, f)?,
3085                        None => Object::Null,
3086                    };
3087                    Ok(Object::Array(vec![
3088                        Object::Name("Separation".into()),
3089                        name,
3090                        alt,
3091                        tint,
3092                    ]))
3093                }
3094                Some("DeviceN") => {
3095                    // [ /DeviceN names alternateSpace tintTransform
3096                    //   (attributes) ] — §8.6.6.5. Resolve the names
3097                    // array (each entry may be an indirect ref),
3098                    // prepare the alternate space recursively, and
3099                    // prepare the n-in/m-out tint-transform function so
3100                    // the content parser sees a self-contained array.
3101                    // The optional attributes dictionary is dropped (its
3102                    // NChannel custom-blending hints are not consulted —
3103                    // §8.6.6.5 lets a conforming reader render through
3104                    // the alternate + tint transform instead).
3105                    let mut it = items.into_iter();
3106                    let _family = it.next();
3107                    let names = match it.next() {
3108                        Some(Object::Reference(id)) => reader.resolve(id)?,
3109                        Some(other) => other,
3110                        None => return Ok(Object::Array(vec![Object::Name("DeviceN".into())])),
3111                    };
3112                    // The names entry must be an array; resolve any
3113                    // indirect colorant-name references one hop.
3114                    let names = match names {
3115                        Object::Array(elems) => {
3116                            let mut resolved = Vec::with_capacity(elems.len());
3117                            for e in elems {
3118                                let r = match e {
3119                                    Object::Reference(id) => reader.resolve(id)?,
3120                                    other => other,
3121                                };
3122                                resolved.push(r);
3123                            }
3124                            Object::Array(resolved)
3125                        }
3126                        other => other,
3127                    };
3128                    let alt = match it.next() {
3129                        Some(a) => prepare_color_space_object(reader, a)?,
3130                        None => Object::Null,
3131                    };
3132                    let tint = match it.next() {
3133                        Some(f) => prepare_function_object(reader, f)?,
3134                        None => Object::Null,
3135                    };
3136                    Ok(Object::Array(vec![
3137                        Object::Name("DeviceN".into()),
3138                        names,
3139                        alt,
3140                        tint,
3141                    ]))
3142                }
3143                _ => Ok(Object::Array(items)),
3144            }
3145        }
3146        other => Ok(other),
3147    }
3148}
3149
3150/// Normalise a PDF function object (§7.10) into a self-contained value
3151/// the content parser can interpret without further document access.
3152///
3153/// A function may be a dictionary (Type 2 / Type 3) or a stream (Type 0
3154/// sampled / Type 4 PostScript-calculator). This content parser
3155/// evaluates the dictionary-shaped Type 2 (exponential, §7.10.3) and
3156/// Type 3 (stitching, §7.10.4) functions, plus the stream-shaped Type 0
3157/// (sampled, §7.10.2) and Type 4 (PostScript-calculator, §7.10.5)
3158/// functions, so:
3159///
3160/// * An indirect reference is dereferenced one hop.
3161/// * A stream's dictionary is surfaced so the common Table 38 entries
3162///   (`/FunctionType`, `/Domain`, `/Range`) and the Table 39 Type 0
3163///   entries (`/Size`, `/BitsPerSample`, `/Encode`, `/Decode`) stay
3164///   reachable. For a Type 0 function the decoded sample body is also
3165///   carried into the dictionary under the synthetic `__Samples` key (a
3166///   `HexString`), mirroring the Indexed-space lookup-stream handling,
3167///   so the content parser sees a self-contained sampled function. For a
3168///   Type 4 function the decoded PostScript program source is carried
3169///   under the synthetic `__Program` key (a `HexString`) the same way,
3170///   so the content parser sees a self-contained calculator function.
3171/// * A Type 3 stitching dictionary's `/Functions` array is prepared
3172///   element-by-element so each sub-function is itself self-contained.
3173fn prepare_function_object(
3174    reader: &mut DocumentReader<'_>,
3175    obj: Object,
3176) -> Result<Object, PdfError> {
3177    let obj = match obj {
3178        Object::Reference(id) => reader.resolve(id)?,
3179        other => other,
3180    };
3181    // Surface a stream as its dictionary so common Table 38 entries
3182    // (/FunctionType, /Domain, /Range) stay reachable. A Type 0 sampled
3183    // function's decoded body is folded in under `__Samples` (§7.10.2);
3184    // any other stream body is dropped (only its parameters are needed).
3185    let mut dict = match obj {
3186        Object::Stream(s) => {
3187            let function_type = s
3188                .dict
3189                .entries()
3190                .iter()
3191                .find(|(k, _)| k == "FunctionType")
3192                .and_then(|(_, v)| match v {
3193                    Object::Integer(n) => Some(*n),
3194                    _ => None,
3195                });
3196            match function_type {
3197                // Type 0 (sampled, §7.10.2): fold the decoded sample
3198                // body into `__Samples`.
3199                Some(0) => {
3200                    let samples = decode_stream(&s)?;
3201                    let mut d = s.dict;
3202                    d.set("__Samples", Object::HexString(samples));
3203                    d
3204                }
3205                // Type 4 (PostScript calculator, §7.10.5): the program
3206                // body lives in the stream, so fold the decoded source
3207                // text into `__Program` mirroring the Type 0 handling.
3208                Some(4) => {
3209                    let program = decode_stream(&s)?;
3210                    let mut d = s.dict;
3211                    d.set("__Program", Object::HexString(program));
3212                    d
3213                }
3214                // Any other stream's body is irrelevant — only its
3215                // parameters are needed.
3216                _ => s.dict,
3217            }
3218        }
3219        Object::Dict(d) => d,
3220        other => return Ok(other),
3221    };
3222    // A Type 3 stitching function references sub-functions in its
3223    // `/Functions` array; recurse so each is self-contained.
3224    if let Some((_, Object::Array(funcs))) = dict
3225        .entries()
3226        .iter()
3227        .find(|(k, _)| k == "Functions")
3228        .map(|(k, v)| (k.clone(), v.clone()))
3229    {
3230        let mut prepared = Vec::with_capacity(funcs.len());
3231        for f in funcs {
3232            prepared.push(prepare_function_object(reader, f)?);
3233        }
3234        dict.set("Functions", Object::Array(prepared));
3235    }
3236    Ok(Object::Dict(dict))
3237}
3238
3239fn extract_stream_data(reader: &mut DocumentReader<'_>, id: ObjectId) -> Result<Vec<u8>, PdfError> {
3240    let obj = reader.resolve(id)?;
3241    let Object::Stream(s) = obj else {
3242        return Err(PdfError::other(format!(
3243            "PDF reader: object {id:?} expected to be a Stream (got {obj:?})"
3244        )));
3245    };
3246    decode_stream(&s)
3247}
3248
3249/// Apply the stream's `/Filter` (if any) to recover the raw payload.
3250///
3251/// Generic decompression filters land here: `FlateDecode` (§7.4.4),
3252/// `LZWDecode` (§7.4.4.2 — round 98), `ASCII85Decode` (§7.4.3),
3253/// `ASCIIHexDecode` (§7.4.2), and `RunLengthDecode` (§7.4.5), in both
3254/// the single-`Name` and the `Array` (filter-chain) forms (§7.4.1).
3255/// Filters are applied in array order so a chain such as
3256/// `[/ASCII85Decode /LZWDecode]` (§7.4.4 Example 2) round-trips.
3257///
3258/// Terminal image codec filters (`DCTDecode`, `JPXDecode`,
3259/// `JBIG2Decode`, `CCITTFaxDecode`) are *not* decoded here — they
3260/// surface to the dedicated round-23 / round-35 image walkers that
3261/// hand their opaque payload to a codec crate. A `/Filter` naming one
3262/// of those is reported as unsupported rather than silently mangled.
3263pub fn decode_stream(stream: &Stream) -> Result<Vec<u8>, PdfError> {
3264    let filter = stream
3265        .dict
3266        .entries()
3267        .iter()
3268        .find(|(k, _)| k == "Filter")
3269        .map(|(_, v)| v.clone());
3270    // The matching `/DecodeParms` slot (or `/DP` abbreviation): a dict
3271    // for a single filter, or a (possibly null-padded) array parallel
3272    // to the `/Filter` array. Used to read LZW's `/EarlyChange`.
3273    let parms = stream
3274        .dict
3275        .entries()
3276        .iter()
3277        .find(|(k, _)| k == "DecodeParms" || k == "DP")
3278        .map(|(_, v)| v.clone());
3279    match filter {
3280        None => Ok(stream.data.clone()),
3281        Some(Object::Name(name)) => apply_filter(&name, &stream.data, parms_for_index(&parms, 0)),
3282        Some(Object::Array(items)) => {
3283            let mut data = stream.data.clone();
3284            for (idx, item) in items.iter().enumerate() {
3285                let Object::Name(name) = item else {
3286                    return Err(PdfError::other(format!(
3287                        "PDF reader: /Filter chain item must be a Name (got {item:?})"
3288                    )));
3289                };
3290                data = apply_filter(name, &data, parms_for_index(&parms, idx))?;
3291            }
3292            Ok(data)
3293        }
3294        Some(other) => Err(PdfError::other(format!(
3295            "PDF reader: /Filter must be a Name or array of Names (got {other:?})"
3296        ))),
3297    }
3298}
3299
3300/// Pull the `/DecodeParms` dictionary that lines up with filter slot
3301/// `idx`. A bare dict applies to the (single) filter at index 0; an
3302/// array is indexed positionally, treating `null` and out-of-range
3303/// slots as "no parameters" per §7.4.1.
3304fn parms_for_index(parms: &Option<Object>, idx: usize) -> Option<&Dict> {
3305    match parms {
3306        Some(Object::Dict(d)) if idx == 0 => Some(d),
3307        Some(Object::Array(items)) => match items.get(idx) {
3308            Some(Object::Dict(d)) => Some(d),
3309            _ => None,
3310        },
3311        _ => None,
3312    }
3313}
3314
3315/// Apply one named generic filter to `data`.
3316///
3317/// For `FlateDecode` / `LZWDecode`, the `/DecodeParms /Predictor`
3318/// post-filter (§7.4.4.4) is applied to the decompressed bytes when
3319/// present — a stream whose `/DecodeParms` carries `/Predictor` > 1
3320/// (PNG predictors 10..=15 or TIFF Predictor 2) is un-differenced
3321/// before being returned. `/Colors`, `/BitsPerComponent`, and
3322/// `/Columns` come from the same dict (Table 8 defaults: 1 / 8 / 1).
3323fn apply_filter(name: &str, data: &[u8], parms: Option<&Dict>) -> Result<Vec<u8>, PdfError> {
3324    use crate::reader::filters;
3325    match name {
3326        "FlateDecode" | "Fl" => apply_predictor_post(filters::flate_decompress(data)?, parms),
3327        "LZWDecode" | "LZW" => {
3328            // `/EarlyChange` defaults to 1 (§7.4.4.3 Table 8); only 0
3329            // postpones the width bump.
3330            let early = parms
3331                .and_then(|d| d.entries().iter().find(|(k, _)| k == "EarlyChange"))
3332                .and_then(|(_, v)| match v {
3333                    Object::Integer(n) => Some(*n != 0),
3334                    _ => None,
3335                })
3336                .unwrap_or(true);
3337            apply_predictor_post(filters::lzw_decode_with_early_change(data, early)?, parms)
3338        }
3339        "ASCII85Decode" | "A85" => filters::ascii85_decode(data),
3340        "ASCIIHexDecode" | "AHx" => filters::ascii_hex_decode(data),
3341        "RunLengthDecode" | "RL" => filters::run_length_decode(data),
3342        other => Err(PdfError::other(format!(
3343            "PDF reader: filter `{other}` not yet supported by decode_stream \
3344             (image codec filters DCT/JPX/JBIG2/CCITTFax route through the image walkers)"
3345        ))),
3346    }
3347}
3348
3349/// Apply the `/DecodeParms /Predictor` post-filter to `LZWDecode` /
3350/// `FlateDecode` output if the slot's parameter dict requests one
3351/// (§7.4.4.4 Table 8 / Table 10). When `/Predictor` is absent or `1`
3352/// the bytes pass through unchanged.
3353fn apply_predictor_post(data: Vec<u8>, parms: Option<&Dict>) -> Result<Vec<u8>, PdfError> {
3354    use crate::reader::filters::{apply_predictor, PredictorParams};
3355    let Some(parms) = parms else {
3356        return Ok(data);
3357    };
3358    let int = |key: &str| -> Option<i64> {
3359        parms
3360            .entries()
3361            .iter()
3362            .find(|(k, _)| k == key)
3363            .and_then(|(_, v)| match v {
3364                Object::Integer(n) => Some(*n),
3365                _ => None,
3366            })
3367    };
3368    let predictor = int("Predictor").unwrap_or(1);
3369    if predictor <= 1 {
3370        return Ok(data);
3371    }
3372    let def = PredictorParams::default();
3373    let params = PredictorParams {
3374        predictor,
3375        colors: int("Colors")
3376            .map(|n| n.max(0) as usize)
3377            .unwrap_or(def.colors),
3378        bits_per_component: int("BitsPerComponent")
3379            .map(|n| n.max(0) as usize)
3380            .unwrap_or(def.bits_per_component),
3381        columns: int("Columns")
3382            .map(|n| n.max(0) as usize)
3383            .unwrap_or(def.columns),
3384    };
3385    apply_predictor(&data, &params)
3386}
3387
3388fn decode_metadata(info: Object) -> Result<Metadata, PdfError> {
3389    let Object::Dict(d) = info else {
3390        return Err(PdfError::other(format!(
3391            "PDF reader: /Info must be a dict (got {info:?})"
3392        )));
3393    };
3394    let mut m = Metadata::default();
3395    for (k, v) in d.entries() {
3396        match k.as_str() {
3397            "Title" => m.title = decode_text(v),
3398            "Author" => m.author = decode_text(v),
3399            "Subject" => m.subject = decode_text(v),
3400            "Keywords" => {
3401                if let Some(s) = decode_text(v) {
3402                    // Reverse the writer's `keywords.join(", ")` —
3403                    // split + trim. Falls back to a single-element
3404                    // vec when the string has no separator.
3405                    m.keywords = s.split(',').map(|p| p.trim().to_owned()).collect();
3406                }
3407            }
3408            "Creator" => m.creator = decode_text(v),
3409            "Producer" => m.producer = decode_text(v),
3410            "CreationDate" => m.created_at = decode_text(v).map(pdf_date_to_iso8601),
3411            "ModDate" => m.modified_at = decode_text(v).map(pdf_date_to_iso8601),
3412            other => {
3413                if let Some(s) = decode_text(v) {
3414                    m.custom.insert(other.to_owned(), s);
3415                }
3416            }
3417        }
3418    }
3419    Ok(m)
3420}
3421
3422/// Convert a PDF date `D:YYYYMMDDHHmmSSOHH'mm'` back to ISO-8601.
3423/// Inputs that don't start with `D:` are returned as-is so the
3424/// scene's metadata round-trip is lossless for non-date strings.
3425pub fn pdf_date_to_iso8601(s: String) -> String {
3426    let bytes = s.as_bytes();
3427    if !bytes.starts_with(b"D:") {
3428        return s;
3429    }
3430    let rest = &bytes[2..];
3431    if rest.len() < 4 {
3432        return s.clone();
3433    }
3434    let mut out = String::with_capacity(25);
3435    let year = &rest[0..4.min(rest.len())];
3436    out.push_str(&String::from_utf8_lossy(year));
3437    if rest.len() >= 6 {
3438        out.push('-');
3439        out.push_str(&String::from_utf8_lossy(&rest[4..6]));
3440    }
3441    if rest.len() >= 8 {
3442        out.push('-');
3443        out.push_str(&String::from_utf8_lossy(&rest[6..8]));
3444    }
3445    if rest.len() >= 10 {
3446        out.push('T');
3447        out.push_str(&String::from_utf8_lossy(&rest[8..10]));
3448    }
3449    if rest.len() >= 12 {
3450        out.push(':');
3451        out.push_str(&String::from_utf8_lossy(&rest[10..12]));
3452    }
3453    if rest.len() >= 14 {
3454        out.push(':');
3455        out.push_str(&String::from_utf8_lossy(&rest[12..14]));
3456    }
3457    // Zone designator.
3458    if rest.len() == 15 && rest[14] == b'Z' {
3459        out.push('Z');
3460    } else if rest.len() >= 17 && (rest[14] == b'+' || rest[14] == b'-') {
3461        // ±HH'mm'  → ±HH:mm
3462        out.push(rest[14] as char);
3463        out.push_str(&String::from_utf8_lossy(&rest[15..17]));
3464        // Skip the apostrophe; mm follows.
3465        if rest.len() >= 20 && rest[17] == b'\'' {
3466            out.push(':');
3467            out.push_str(&String::from_utf8_lossy(&rest[18..20]));
3468        }
3469    }
3470    out
3471}
3472
3473fn decode_text(v: &Object) -> Option<String> {
3474    match v {
3475        Object::LiteralString(b) => Some(String::from_utf8_lossy(b).into_owned()),
3476        Object::HexString(b) => {
3477            // The writer uses UTF-16BE-with-BOM for non-ASCII; decode
3478            // back to a Rust String.
3479            if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
3480                let utf16: Vec<u16> = b[2..]
3481                    .chunks_exact(2)
3482                    .map(|c| u16::from_be_bytes([c[0], c[1]]))
3483                    .collect();
3484                Some(String::from_utf16_lossy(&utf16))
3485            } else {
3486                Some(String::from_utf8_lossy(b).into_owned())
3487            }
3488        }
3489        _ => None,
3490    }
3491}
3492
3493fn number_to_f32(o: &Object) -> Result<f32, PdfError> {
3494    match o {
3495        Object::Integer(n) => Ok(*n as f32),
3496        Object::Real(f) => Ok(*f as f32),
3497        other => Err(PdfError::other(format!(
3498            "PDF reader: expected number, got {other:?}"
3499        ))),
3500    }
3501}
3502
3503// Suppress dead-code warning on a small helper that the round-3
3504// Scene assembly doesn't yet use — keeps the writer/reader symmetry
3505// obvious and lets round-4+ wire it up.
3506#[allow(dead_code)]
3507fn empty_root() -> Group {
3508    Group::default()
3509}
3510
3511#[allow(dead_code)]
3512fn empty_path_node() -> PathNode {
3513    PathNode {
3514        path: Path {
3515            commands: vec![PathCommand::Close],
3516        },
3517        fill: Some(Paint::Solid(Rgba::opaque(0, 0, 0))),
3518        stroke: None,
3519        fill_rule: FillRule::NonZero,
3520    }
3521}
3522
3523// `Node` is referenced by our parsed content stream output — make
3524// sure the import isn't pruned by dead-code analysis when this
3525// commit's tests don't directly observe a Node variant.
3526#[allow(dead_code)]
3527fn _node_imported(_: Node) {}
3528
3529#[cfg(test)]
3530mod tests {
3531    use super::*;
3532    use crate::write_pdf_from_scene;
3533
3534    fn make_scene_with_one_red_rect() -> Scene {
3535        use oxideav_core::vector::{
3536            FillRule, Group, Node, Paint, Path, PathCommand, PathNode, Point, Rgba, VectorFrame,
3537        };
3538        let mut p = Path::new();
3539        p.commands.push(PathCommand::MoveTo(Point::new(10.0, 10.0)));
3540        p.commands.push(PathCommand::LineTo(Point::new(90.0, 10.0)));
3541        p.commands.push(PathCommand::LineTo(Point::new(90.0, 90.0)));
3542        p.commands.push(PathCommand::LineTo(Point::new(10.0, 90.0)));
3543        p.commands.push(PathCommand::Close);
3544        let frame = VectorFrame {
3545            width: 100.0,
3546            height: 100.0,
3547            view_box: None,
3548            root: Group {
3549                children: vec![Node::Path(PathNode {
3550                    path: p,
3551                    fill: Some(Paint::Solid(Rgba::opaque(255, 0, 0))),
3552                    stroke: None,
3553                    fill_rule: FillRule::NonZero,
3554                })],
3555                ..Group::default()
3556            },
3557            pts: None,
3558            time_base: TimeBase::new(1, 1),
3559        };
3560        let mut page = Page::new(100.0, 100.0);
3561        page.content = frame;
3562        Scene {
3563            pages: Some(vec![page]),
3564            ..Scene::default()
3565        }
3566    }
3567
3568    #[test]
3569    fn read_pdf_to_scene_roundtrip_single_page() {
3570        let scene = make_scene_with_one_red_rect();
3571        let pdf = write_pdf_from_scene(&scene).unwrap();
3572        let parsed = read_pdf_to_scene(&pdf).unwrap();
3573        let pages = parsed.pages.expect("scene has pages");
3574        assert_eq!(pages.len(), 1);
3575        assert_eq!(pages[0].width, 100.0);
3576        assert_eq!(pages[0].height, 100.0);
3577        // Walk the rebuilt vector frame for a path with the red fill.
3578        let root = &pages[0].content.root;
3579        // The reader produces a top-level frame containing one
3580        // `q ... Q`-derived child group; that child group contains
3581        // the path node.
3582        // The reader's q/Q nesting mirrors the writer's emission:
3583        //   root q (frame group walker)
3584        //     per-path q
3585        //       path
3586        //     Q
3587        //   Q
3588        // — so the path is two Group hops below the root.
3589        let path_node = find_first_path(root).expect("at least one PathNode in the tree");
3590        match &path_node.fill {
3591            Some(Paint::Solid(rgba)) => assert_eq!((rgba.r, rgba.g, rgba.b), (255, 0, 0)),
3592            other => panic!("expected solid red, got {other:?}"),
3593        }
3594    }
3595
3596    fn find_first_path(group: &Group) -> Option<&PathNode> {
3597        for child in &group.children {
3598            match child {
3599                Node::Path(p) => return Some(p),
3600                Node::Group(g) => {
3601                    if let Some(p) = find_first_path(g) {
3602                        return Some(p);
3603                    }
3604                }
3605                _ => {}
3606            }
3607        }
3608        None
3609    }
3610
3611    #[test]
3612    fn read_pdf_to_scene_roundtrip_multi_page() {
3613        use oxideav_core::vector::Rgba;
3614        let mut scene = make_scene_with_one_red_rect();
3615        let mut p2 = Page::new(200.0, 100.0);
3616        p2.content.width = 200.0;
3617        p2.content.height = 100.0;
3618        // Make a green rect on page 2.
3619        use oxideav_core::vector::{Group, Node, Paint, Path, PathCommand, PathNode, Point};
3620        let mut path = Path::new();
3621        path.commands
3622            .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
3623        path.commands
3624            .push(PathCommand::LineTo(Point::new(50.0, 0.0)));
3625        path.commands
3626            .push(PathCommand::LineTo(Point::new(50.0, 50.0)));
3627        path.commands.push(PathCommand::Close);
3628        p2.content.root = Group {
3629            children: vec![Node::Path(PathNode {
3630                path,
3631                fill: Some(Paint::Solid(Rgba::opaque(0, 255, 0))),
3632                stroke: None,
3633                fill_rule: FillRule::NonZero,
3634            })],
3635            ..Group::default()
3636        };
3637        scene.pages.as_mut().unwrap().push(p2);
3638        let pdf = write_pdf_from_scene(&scene).unwrap();
3639        let parsed = read_pdf_to_scene(&pdf).unwrap();
3640        let pages = parsed.pages.expect("scene has pages");
3641        assert_eq!(pages.len(), 2);
3642        assert_eq!(pages[0].width, 100.0);
3643        assert_eq!(pages[1].width, 200.0);
3644    }
3645
3646    #[test]
3647    fn read_pdf_metadata_roundtrip() {
3648        let mut scene = make_scene_with_one_red_rect();
3649        scene.metadata = Metadata {
3650            title: Some("Round 3 Doc".into()),
3651            author: Some("Mark".into()),
3652            subject: Some("Reader test".into()),
3653            keywords: vec!["pdf".into(), "round3".into()],
3654            creator: Some("MyApp".into()),
3655            producer: Some("oxideav-pdf".into()),
3656            created_at: Some("2026-05-04T12:30:45Z".into()),
3657            modified_at: Some("2026-05-04T13:00:00Z".into()),
3658            ..Metadata::default()
3659        };
3660        let pdf = write_pdf_from_scene(&scene).unwrap();
3661        let parsed = read_pdf_to_scene(&pdf).unwrap();
3662        assert_eq!(parsed.metadata.title.as_deref(), Some("Round 3 Doc"));
3663        assert_eq!(parsed.metadata.author.as_deref(), Some("Mark"));
3664        assert_eq!(parsed.metadata.subject.as_deref(), Some("Reader test"));
3665        assert_eq!(parsed.metadata.creator.as_deref(), Some("MyApp"));
3666        assert_eq!(parsed.metadata.producer.as_deref(), Some("oxideav-pdf"));
3667        assert_eq!(
3668            parsed.metadata.keywords,
3669            vec!["pdf".to_string(), "round3".to_string()]
3670        );
3671        // PDF dates round-trip through `pdf_date_to_iso8601`.
3672        assert_eq!(
3673            parsed.metadata.created_at.as_deref(),
3674            Some("2026-05-04T12:30:45Z")
3675        );
3676    }
3677
3678    #[test]
3679    fn read_pdf_custom_metadata_roundtrip() {
3680        let mut scene = make_scene_with_one_red_rect();
3681        let mut custom = std::collections::BTreeMap::new();
3682        custom.insert("dc:rights".into(), "(c) 2026 Karpeles".into());
3683        custom.insert("Trapped".into(), "False".into());
3684        scene.metadata = Metadata {
3685            custom,
3686            ..Metadata::default()
3687        };
3688        let pdf = write_pdf_from_scene(&scene).unwrap();
3689        let parsed = read_pdf_to_scene(&pdf).unwrap();
3690        assert_eq!(
3691            parsed.metadata.custom.get("dc:rights").map(String::as_str),
3692            Some("(c) 2026 Karpeles")
3693        );
3694        assert_eq!(
3695            parsed.metadata.custom.get("Trapped").map(String::as_str),
3696            Some("False")
3697        );
3698    }
3699
3700    #[test]
3701    fn read_pdf_unicode_metadata_roundtrip() {
3702        let mut scene = make_scene_with_one_red_rect();
3703        scene.metadata = Metadata {
3704            title: Some("日本語".into()),
3705            ..Metadata::default()
3706        };
3707        let pdf = write_pdf_from_scene(&scene).unwrap();
3708        let parsed = read_pdf_to_scene(&pdf).unwrap();
3709        assert_eq!(parsed.metadata.title.as_deref(), Some("日本語"));
3710    }
3711
3712    #[test]
3713    fn pdf_date_to_iso8601_format() {
3714        assert_eq!(
3715            pdf_date_to_iso8601("D:20260504123045Z".to_string()),
3716            "2026-05-04T12:30:45Z"
3717        );
3718        assert_eq!(
3719            pdf_date_to_iso8601("D:20260504123045+09'00'".to_string()),
3720            "2026-05-04T12:30:45+09:00"
3721        );
3722    }
3723
3724    #[test]
3725    fn no_metadata_yields_default() {
3726        let scene = make_scene_with_one_red_rect();
3727        let pdf = write_pdf_from_scene(&scene).unwrap();
3728        let parsed = read_pdf_to_scene(&pdf).unwrap();
3729        assert!(parsed.metadata.title.is_none());
3730        assert!(parsed.metadata.custom.is_empty());
3731    }
3732
3733    /// `decode_stream` on a `/FlateDecode` stream that also carries a
3734    /// `/DecodeParms /Predictor 12` (PNG-Up) un-differences the body
3735    /// after inflating (§7.4.4.4). The expected output is the original
3736    /// pre-predictor sample bytes.
3737    #[test]
3738    fn decode_stream_applies_flate_png_predictor() {
3739        // Two rows of 3 single-byte samples (Colors=1, BPC=8,
3740        // Columns=3): [10,20,30] and [11,22,33]. PNG-encoded with a
3741        // None tag on row 0 and an Up tag (deltas) on row 1.
3742        let predicted: &[u8] = &[
3743            0, 10, 20, 30, // row 0: tag None
3744            2, 1, 2, 3, // row 1: tag Up
3745        ];
3746        let compressed = crate::zlib::flate_compress(predicted);
3747
3748        let dict = Dict::new()
3749            .with("Filter", Object::Name("FlateDecode".into()))
3750            .with(
3751                "DecodeParms",
3752                Object::Dict(
3753                    Dict::new()
3754                        .with("Predictor", Object::Integer(12))
3755                        .with("Columns", Object::Integer(3)),
3756                ),
3757            );
3758        let stream = Stream::new(dict, compressed);
3759        let out = decode_stream(&stream).unwrap();
3760        assert_eq!(out, [10u8, 20, 30, 11, 22, 33]);
3761    }
3762
3763    /// A `/FlateDecode` stream with no `/DecodeParms` (or `/Predictor 1`)
3764    /// returns the inflated bytes unchanged — the predictor path is a
3765    /// no-op there.
3766    #[test]
3767    fn decode_stream_flate_without_predictor_is_passthrough() {
3768        let raw = b"hello predictor-free world";
3769        let compressed = crate::zlib::flate_compress(raw);
3770
3771        let dict = Dict::new().with("Filter", Object::Name("FlateDecode".into()));
3772        let stream = Stream::new(dict, compressed);
3773        assert_eq!(decode_stream(&stream).unwrap(), raw);
3774    }
3775
3776    /// End-to-end document path for a Type 4 (free-form Gouraud) mesh
3777    /// shading carried as a `/FlateDecode` stream resource: opening the
3778    /// hand-rolled PDF, resolving its `/Resources /Shading`, and feeding
3779    /// the surfaced dict to `evaluate_mesh_shading` produces the
3780    /// triangle. Verifies `resolve_shading_resources` folds the
3781    /// decompressed `__MeshData` body (§8.7.4.5.5).
3782    #[test]
3783    fn resolve_shading_folds_mesh_stream_body() {
3784        use crate::reader::content::evaluate_mesh_shading_for_test;
3785        // One all-coloured triangle: f=0 (0,0)R, f=0 (255,0)G, f=0 (0,255)B,
3786        // 8-bit flag / coord / component, byte-aligned per vertex.
3787        let mut body: Vec<u8> = Vec::new();
3788        for (x, y, r, g, b) in [
3789            (0u8, 0u8, 255u8, 0u8, 0u8),
3790            (255, 0, 0, 255, 0),
3791            (0, 255, 0, 0, 255),
3792        ] {
3793            body.extend_from_slice(&[0, x, y, r, g, b]); // flag, x, y, r, g, b
3794        }
3795        let compressed = crate::zlib::flate_compress(&body);
3796
3797        // Hand-roll a minimal PDF whose page `/Resources /Shading /Sh1`
3798        // points at the mesh stream (object 5).
3799        let mut out: Vec<u8> = Vec::new();
3800        out.extend_from_slice(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
3801        let mut offs: Vec<usize> = vec![0];
3802        offs.push(out.len());
3803        out.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
3804        offs.push(out.len());
3805        out.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n");
3806        offs.push(out.len());
3807        out.extend_from_slice(
3808            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
3809              /Resources << /Shading << /Sh1 5 0 R >> >> >>\nendobj\n",
3810        );
3811        offs.push(out.len());
3812        out.extend_from_slice(b"4 0 obj\n<< >>\nendobj\n");
3813        offs.push(out.len());
3814        let header = format!(
3815            "5 0 obj\n<< /ShadingType 4 /ColorSpace /DeviceRGB \
3816             /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 \
3817             /Decode [0 1 0 1 0 1 0 1 0 1] /Filter /FlateDecode /Length {} >>\nstream\n",
3818            compressed.len()
3819        );
3820        out.extend_from_slice(header.as_bytes());
3821        out.extend_from_slice(&compressed);
3822        out.extend_from_slice(b"\nendstream\nendobj\n");
3823
3824        let xref_off = out.len();
3825        out.extend_from_slice(b"xref\n0 6\n");
3826        out.extend_from_slice(b"0000000000 65535 f \n");
3827        for &o in &offs[1..] {
3828            out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
3829        }
3830        out.extend_from_slice(
3831            format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF\n")
3832                .as_bytes(),
3833        );
3834
3835        let mut reader = DocumentReader::open(&out).expect("open");
3836        let resources = Dict::new().with(
3837            "Shading",
3838            Object::Dict(Dict::new().with("Sh1", Object::Reference(ObjectId::new(5)))),
3839        );
3840        let resolved = resolve_shading_resources(&mut reader, &resources)
3841            .expect("resolve")
3842            .expect("some shading dict");
3843        let sh1 = match resolved.entries().iter().find(|(k, _)| k == "Sh1") {
3844            Some((_, Object::Dict(d))) => d.clone(),
3845            _ => panic!("Sh1 not a dict"),
3846        };
3847        // The decompressed mesh body was folded into __MeshData.
3848        assert!(sh1.entries().iter().any(|(k, _)| k == "__MeshData"));
3849        let mesh = evaluate_mesh_shading_for_test(&sh1).expect("mesh");
3850        // One triangle with three coloured vertices.
3851        assert!(format!("{mesh:?}").contains("Triangles"));
3852    }
3853}