Skip to main content

stet_pdf_reader/
lib.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! PDF parser, page navigator, and content stream interpreter.
6//!
7//! `stet-pdf-reader` is a self-contained PDF reader: it opens a PDF, walks
8//! its object graph, and interprets each page's content stream into a
9//! `stet_graphics::display_list::DisplayList` that any downstream consumer
10//! (rasterizer, PDF writer, custom output device) can render.
11//!
12//! The crate intentionally has **no dependency on `stet-core`** — it uses
13//! only `stet-fonts` (font parsing) and `stet-graphics` (display list and
14//! ICC types), so it can be used as a standalone PDF parser/renderer
15//! without pulling in the PostScript interpreter.
16//!
17//! # Quick start
18//!
19//! ```no_run
20//! use stet_pdf_reader::PdfDocument;
21//!
22//! let data = std::fs::read("document.pdf")?;
23//! let doc = PdfDocument::from_bytes(&data)?;
24//!
25//! for page in 0..doc.page_count() {
26//!     let display_list = doc.render_page(page, 150.0)?;
27//!     // …consume the display list (rasterize, convert, inspect, etc.)
28//! }
29//! # Ok::<(), Box<dyn std::error::Error>>(())
30//! ```
31//!
32//! With the default `render` feature enabled, [`PdfDocument::render_page_to_rgba`]
33//! skips the display-list-handling boilerplate and produces RGBA pixels
34//! directly via `stet-render`.
35//!
36//! # Encrypted PDFs
37//!
38//! `from_bytes` / `from_bytes_with_icc` try the empty password. If the
39//! file uses a non-empty user password they return
40//! [`PdfError::PasswordRequired`]; the caller can then prompt the user
41//! and retry with [`PdfDocument::from_bytes_with_password`]:
42//!
43//! ```no_run
44//! use stet_pdf_reader::{PdfDocument, PdfError};
45//! use stet_graphics::icc::IccCache;
46//!
47//! let data = std::fs::read("encrypted.pdf")?;
48//! let doc = match PdfDocument::from_bytes(&data) {
49//!     Ok(doc) => doc,
50//!     Err(PdfError::PasswordRequired) => {
51//!         let pw = prompt_user_for_password();
52//!         PdfDocument::from_bytes_with_password(&data, IccCache::new(), pw.as_bytes())?
53//!     }
54//!     Err(e) => return Err(e.into()),
55//! };
56//! # fn prompt_user_for_password() -> String { String::new() }
57//! # Ok::<(), Box<dyn std::error::Error>>(())
58//! ```
59//!
60//! RC4 (40/128-bit), AES-128, and AES-256 (R=5/6) are all supported.
61//!
62//! # Acknowledgements
63//!
64//! JPEG 2000, JBIG2, and CCITT-Fax stream decoding use the
65//! [`hayro-jpeg2000`](https://crates.io/crates/hayro-jpeg2000),
66//! [`hayro-jbig2`](https://crates.io/crates/hayro-jbig2), and
67//! [`hayro-ccitt`](https://crates.io/crates/hayro-ccitt) crates from the
68//! [hayro](https://github.com/LaurenzV/hayro) PDF renderer by Laurenz
69//! Stampfl. Big thanks to the hayro project for factoring those decoders
70//! out as reusable crates — `stet-pdf-reader` would not cover the full
71//! PDF stream-filter surface without them.
72
73pub mod content;
74pub mod crypto;
75pub mod error;
76pub mod filters;
77pub mod lexer;
78pub mod objects;
79pub mod page_tree;
80pub mod resolver;
81pub mod resources;
82pub mod xref;
83
84pub use error::PdfError;
85pub use objects::{PdfDict, PdfObj};
86pub use page_tree::PageInfo;
87
88use content::ContentInterpreter;
89use resolver::Resolver;
90use std::collections::HashSet;
91use std::sync::Arc;
92use stet_fonts::geometry::Matrix;
93use stet_graphics::display_list::DisplayList;
94use stet_graphics::icc::IccCache;
95
96/// Font data provider: maps a font file name (e.g. "NimbusSans-Regular") to raw .t1 bytes.
97///
98/// Used for environments without filesystem access (WASM) where fonts are embedded.
99pub type FontProvider = Arc<dyn Fn(&str) -> Option<Vec<u8>> + Send + Sync>;
100
101/// A parsed PDF document.
102pub struct PdfDocument<'a> {
103    resolver: Resolver<'a>,
104    pages: Vec<PageInfo>,
105    icc_cache: IccCache,
106    font_provider: Option<FontProvider>,
107    /// When false (default), PDF overprint flags (OP/op) are suppressed —
108    /// skips the expensive CMYK buffer simulation that most viewers omit.
109    overprint: bool,
110    /// Object numbers of Optional Content Groups that are OFF by default.
111    /// Parsed from the catalog's /OCProperties /D /OFF array.
112    ocg_off: HashSet<u32>,
113    /// Decompressed ICC profile bytes from the first /OutputIntents entry's
114    /// /DestOutputProfile stream, if present. Used to match the document's
115    /// intended CMYK rendering (ISO Coated v2, SWOP, etc.) at render time.
116    output_intent_icc: Option<Vec<u8>>,
117}
118
119impl<'a> PdfDocument<'a> {
120    /// Parse a PDF from bytes.
121    pub fn from_bytes(data: &'a [u8]) -> Result<Self, PdfError> {
122        let mut icc_cache = IccCache::new();
123        icc_cache.search_system_cmyk_profile();
124        Self::from_bytes_inner(data, icc_cache, b"")
125    }
126
127    /// Parse a PDF from bytes, using a pre-loaded ICC cache.
128    ///
129    /// Use this when the caller already has an `IccCache` with the system
130    /// CMYK profile loaded (e.g., from the PostScript interpreter context).
131    pub fn from_bytes_with_icc(data: &'a [u8], icc_cache: IccCache) -> Result<Self, PdfError> {
132        Self::from_bytes_inner(data, icc_cache, b"")
133    }
134
135    /// Parse a PDF from bytes using a user-supplied password.
136    ///
137    /// Returns `PdfError::PasswordRequired` if the password does not
138    /// match; callers can retry by calling this again with a different
139    /// password.
140    pub fn from_bytes_with_password(
141        data: &'a [u8],
142        icc_cache: IccCache,
143        password: &[u8],
144    ) -> Result<Self, PdfError> {
145        Self::from_bytes_inner(data, icc_cache, password)
146    }
147
148    fn from_bytes_inner(
149        data: &'a [u8],
150        icc_cache: IccCache,
151        password: &[u8],
152    ) -> Result<Self, PdfError> {
153        // Validate header — PDF spec allows up to 1024 bytes before %PDF-
154        if !has_pdf_header(data) {
155            return Err(PdfError::NotAPdf);
156        }
157
158        let xref = xref::parse_xref(data)?;
159
160        // Handle encryption. /Encrypt null means no encryption (some
161        // generators emit this).
162        let encryption = if let Some(encrypt_ref) = xref.trailer.get(b"Encrypt") {
163            if matches!(encrypt_ref, crate::objects::PdfObj::Null) {
164                None
165            } else {
166                // Temporary resolver (without encryption) to dereference
167                // the Encrypt dict itself.
168                let temp_resolver = Resolver::new(data, &xref);
169                let encrypt_obj = temp_resolver.deref(encrypt_ref)?;
170                let encrypt_dict = encrypt_obj
171                    .as_dict()
172                    .ok_or(PdfError::Other("Encrypt is not a dict".into()))?;
173
174                let file_id = xref
175                    .trailer
176                    .get_array(b"ID")
177                    .and_then(|arr| arr.first()?.as_str().map(|s| s.to_vec()))
178                    .unwrap_or_default();
179
180                Some(crypto::EncryptionState::try_open_with_password(
181                    encrypt_dict,
182                    &xref.trailer,
183                    &file_id,
184                    password,
185                )?)
186            }
187        } else {
188            None
189        };
190
191        let resolver = Resolver::with_encryption(data, xref, encryption);
192        let pages = page_tree::collect_pages(&resolver)?;
193        let ocg_off = parse_ocg_off(&resolver);
194        let output_intent_icc = parse_output_intent_icc(&resolver);
195
196        Ok(Self {
197            resolver,
198            pages,
199            icc_cache,
200            font_provider: None,
201            overprint: true,
202            ocg_off,
203            output_intent_icc,
204        })
205    }
206
207    /// Enable or disable PDF overprint simulation.
208    ///
209    /// Enabled by default. When disabled, OP/op flags in graphics state dicts
210    /// are ignored, avoiding CMYK buffer tracking.
211    pub fn set_overprint(&mut self, enabled: bool) {
212        self.overprint = enabled;
213    }
214
215    /// Set a font data provider for environments without filesystem access.
216    pub fn set_font_provider(&mut self, provider: FontProvider) {
217        self.font_provider = Some(provider);
218    }
219
220    /// Number of pages in the document.
221    pub fn page_count(&self) -> usize {
222        self.pages.len()
223    }
224
225    /// Page dimensions in points (width, height), accounting for rotation.
226    pub fn page_size(&self, page: usize) -> Result<(f64, f64), PdfError> {
227        let info = self
228            .pages
229            .get(page)
230            .ok_or(PdfError::PageOutOfRange(page, self.pages.len()))?;
231        let [llx, lly, urx, ury] = info.crop_box;
232        let (w, h) = ((urx - llx).abs(), (ury - lly).abs());
233        match info.rotate.rem_euclid(360) {
234            90 | 270 => Ok((h, w)),
235            _ => Ok((w, h)),
236        }
237    }
238
239    /// Get page info (MediaBox, CropBox, rotation, resources).
240    pub fn page_info(&self, page: usize) -> Result<&PageInfo, PdfError> {
241        self.pages
242            .get(page)
243            .ok_or(PdfError::PageOutOfRange(page, self.pages.len()))
244    }
245
246    /// Get the decompressed content stream bytes for a page.
247    /// If the page has multiple content streams, they are concatenated
248    /// with a newline separator.
249    pub fn page_contents(&self, page: usize) -> Result<Vec<u8>, PdfError> {
250        let info = self
251            .pages
252            .get(page)
253            .ok_or(PdfError::PageOutOfRange(page, self.pages.len()))?;
254
255        if info.contents.is_empty() {
256            return Ok(Vec::new());
257        }
258
259        let mut result = Vec::new();
260        for (i, &(obj_num, gen_num)) in info.contents.iter().enumerate() {
261            // Skip content stream refs that fail (e.g., dict without stream body
262            // in malformed PDFs). Continue with remaining streams.
263            match self.resolver.stream_data(obj_num, gen_num) {
264                Ok(data) => {
265                    if i > 0 && !result.is_empty() {
266                        result.push(b'\n');
267                    }
268                    result.extend_from_slice(&data);
269                }
270                Err(_) => continue,
271            }
272        }
273
274        Ok(result)
275    }
276
277    /// Render a page to a DisplayList at the given DPI.
278    ///
279    /// The display list uses device-space coordinates (paths pre-transformed
280    /// through the initial CTM). The initial CTM applies DPI scaling, Y-flip,
281    /// and CropBox offset.
282    pub fn render_page(&self, page: usize, dpi: f64) -> Result<DisplayList, PdfError> {
283        let info = self
284            .pages
285            .get(page)
286            .ok_or(PdfError::PageOutOfRange(page, self.pages.len()))?;
287
288        let [llx, lly, urx, ury] = info.crop_box;
289        let (page_w, page_h) = ((urx - llx).abs(), (ury - lly).abs());
290
291        // Build initial CTM: scale by dpi/72, Y-flip (PDF Y-up → device Y-down),
292        // and offset by CropBox origin.
293        let scale = dpi / 72.0;
294        let ctm = match info.rotate.rem_euclid(360) {
295            90 => {
296                // Rotate 90° CW + Y-flip: (x,y) → (y*s, x*s)
297                Matrix::new(0.0, scale, scale, 0.0, 0.0, 0.0).concat(&Matrix::translate(-llx, -lly))
298            }
299            180 => {
300                // Rotate 180° + Y-flip = just X-flip
301                Matrix::new(-scale, 0.0, 0.0, scale, page_w * scale, 0.0)
302                    .concat(&Matrix::translate(-llx, -lly))
303            }
304            270 => {
305                // Rotate 270° CW + Y-flip: (x,y) → ((page_h-y)*s, (page_w-x)*s)
306                Matrix::new(0.0, -scale, -scale, 0.0, page_h * scale, page_w * scale)
307                    .concat(&Matrix::translate(-llx, -lly))
308            }
309            _ => {
310                // No rotation: scale + Y-flip + CropBox offset
311                // PDF (0,0) at bottom-left → device (0, page_h*scale) at top-left
312                Matrix::new(scale, 0.0, 0.0, -scale, -llx * scale, ury * scale)
313            }
314        };
315
316        // Get page content stream
317        let content_data = self.page_contents(page)?;
318
319        // Interpret content stream
320        let mut interpreter = ContentInterpreter::new(
321            &self.resolver,
322            info.resources.clone(),
323            ctm,
324            &self.icc_cache,
325            self.font_provider.clone(),
326            self.overprint,
327            &self.ocg_off,
328        );
329
330        // Check if the page has a DeviceCMYK transparency group — if so,
331        // RGB colors need round-tripping through CMYK to match compositing
332        // in CMYK space (mutes saturated out-of-gamut RGB colors).
333        let page_group_is_cmyk = if let Ok(page_obj) = self.resolver.resolve(info.obj_num, 0)
334            && let Some(page_dict) = page_obj.as_dict()
335            && let Some(group_obj) = page_dict.get(b"Group")
336            && let Ok(group_resolved) = self.resolver.deref(group_obj)
337            && let Some(group_dict) = group_resolved.as_dict()
338            && group_dict.get_name(b"CS") == Some(b"DeviceCMYK")
339        {
340            interpreter.set_page_group_cmyk();
341            true
342        } else {
343            false
344        };
345
346        // Render page content
347        if let Err(e) = interpreter.interpret_stream_public(&content_data) {
348            eprintln!("warning: content stream error: {}", e);
349        }
350        // Unwind any unbalanced q's left by the content stream.
351        interpreter.unwind_gstate_stack();
352
353        // Render annotation appearance streams (form field values, stamps, etc.)
354        if !info.annots.is_empty() {
355            interpreter.reset_clip_for_annotations();
356            for &(n, g) in &info.annots {
357                let _ = interpreter.render_annotation(n, g);
358            }
359        }
360
361        let mut dl = interpreter.into_display_list();
362        if page_group_is_cmyk {
363            dl.set_page_group_color_space(stet_graphics::display_list::GroupColorSpace::DeviceCMYK);
364        }
365        Ok(dl)
366    }
367
368    /// Render a page to RGBA pixel data at the given DPI.
369    ///
370    /// Returns (pixel_data, width, height). Pixel data is RGBA, 4 bytes per pixel.
371    #[cfg(feature = "render")]
372    pub fn render_page_to_rgba(
373        &self,
374        page: usize,
375        dpi: f64,
376    ) -> Result<(Vec<u8>, u32, u32), PdfError> {
377        let (page_w, page_h) = self.page_size(page)?;
378        let scale = dpi / 72.0;
379        let pixel_w = (page_w * scale).round() as u32;
380        let pixel_h = (page_h * scale).round() as u32;
381
382        let display_list = self.render_page(page, dpi)?;
383
384        let rgba = stet_render::render_to_rgba(
385            &display_list,
386            pixel_w,
387            pixel_h,
388            dpi,
389            Some(&self.icc_cache),
390            false,
391        );
392
393        Ok((rgba, pixel_w, pixel_h))
394    }
395
396    /// Access the ICC color profile cache.
397    pub fn icc_cache(&self) -> &IccCache {
398        &self.icc_cache
399    }
400
401    /// Decompressed ICC profile bytes from the PDF's OutputIntent, if any.
402    /// PDF/X files declare their intended CMYK rendering space here (e.g.
403    /// ISO Coated v2 300% (ECI)); using it at render time matches the
404    /// document author's colour expectations, which system-default profiles
405    /// (GS `default_cmyk.icc`, FOGRA39) often approximate only coarsely.
406    pub fn output_intent_icc(&self) -> Option<&[u8]> {
407        self.output_intent_icc.as_deref()
408    }
409
410    /// Register the PDF's OutputIntent ICC profile as the default CMYK profile
411    /// in this document's ICC cache, replacing whatever was loaded from
412    /// `search_system_cmyk_profile`. Returns `true` when the profile was
413    /// present and registered.
414    pub fn apply_output_intent_as_default_cmyk(&mut self) -> bool {
415        let Some(bytes) = self.output_intent_icc.as_deref() else {
416            return false;
417        };
418        let Some(hash) = self.icc_cache.register_profile(bytes) else {
419            return false;
420        };
421        self.icc_cache.set_system_cmyk(bytes, hash);
422        true
423    }
424
425    /// Access the resolver for arbitrary object lookups.
426    pub fn resolver(&self) -> &Resolver<'a> {
427        &self.resolver
428    }
429
430    /// Access page info list.
431    pub fn pages(&self) -> &[PageInfo] {
432        &self.pages
433    }
434}
435
436/// Parse the default OFF set from the catalog's OCProperties.
437/// Returns a set of object numbers for OCGs that are OFF by default.
438/// OCGs not listed in either /ON or /OFF are considered ON (PDF spec default).
439fn parse_ocg_off(resolver: &Resolver) -> HashSet<u32> {
440    let mut off = HashSet::new();
441
442    // Get catalog — try trailer /Root first, fall back to scanning if it
443    // doesn't look like a catalog (corrupt incremental updates can swap
444    // /Root and /Info, leaving Root pointing at the Info dict).
445    let mut catalog_owned;
446    let catalog_dict = if let Some(root_ref) = resolver.trailer().get_ref(b"Root") {
447        if let Ok(c) = resolver.resolve(root_ref.0, root_ref.1) {
448            catalog_owned = c;
449            match catalog_owned.as_dict() {
450                Some(d) if d.get(b"OCProperties").is_some() => d,
451                _ => match find_catalog(resolver) {
452                    Some(c) => {
453                        catalog_owned = c;
454                        catalog_owned.as_dict().unwrap()
455                    }
456                    None => return off,
457                },
458            }
459        } else {
460            return off;
461        }
462    } else {
463        return off;
464    };
465
466    // Get OCProperties -> D (default configuration) -> OFF array
467    let oc_props = match catalog_dict.get(b"OCProperties") {
468        Some(obj) => match resolver.deref(obj) {
469            Ok(o) => o,
470            Err(_) => return off,
471        },
472        None => return off,
473    };
474    let oc_dict = match oc_props.as_dict() {
475        Some(d) => d,
476        None => return off,
477    };
478    let d_obj = match oc_dict.get(b"D") {
479        Some(obj) => match resolver.deref(obj) {
480            Ok(o) => o,
481            Err(_) => return off,
482        },
483        None => return off,
484    };
485    let d_dict = match d_obj.as_dict() {
486        Some(d) => d,
487        None => return off,
488    };
489
490    // Collect object numbers from /OFF array (may be an indirect reference)
491    if let Some(off_obj) = d_dict.get(b"OFF") {
492        let off_resolved = resolver.deref(off_obj).unwrap_or_else(|_| off_obj.clone());
493        if let Some(off_arr) = off_resolved.as_array() {
494            for obj in off_arr {
495                if let Some((num, _gen)) = obj.as_ref() {
496                    off.insert(num);
497                }
498            }
499        }
500    }
501
502    off
503}
504
505/// Extract the decompressed ICC profile bytes from the first PDF/X
506/// OutputIntent whose `/DestOutputProfile` is a CMYK ICC stream.
507///
508/// PDF/X files declare their intended CMYK rendering profile (e.g. "ISO
509/// Coated v2 300% (ECI)") via `/Catalog/OutputIntents` with an embedded
510/// `/DestOutputProfile` stream. Using that profile at render time matches
511/// the author's colour expectations; the system-default profiles used as
512/// fallback (GS `default_cmyk.icc`, FOGRA39) only approximate it.
513fn parse_output_intent_icc(resolver: &Resolver) -> Option<Vec<u8>> {
514    let mut catalog_owned;
515    let catalog_dict = if let Some(root_ref) = resolver.trailer().get_ref(b"Root") {
516        if let Ok(c) = resolver.resolve(root_ref.0, root_ref.1) {
517            catalog_owned = c;
518            match catalog_owned.as_dict() {
519                Some(d) if d.get(b"OutputIntents").is_some() => d,
520                _ => {
521                    catalog_owned = find_catalog(resolver)?;
522                    catalog_owned.as_dict()?
523                }
524            }
525        } else {
526            catalog_owned = find_catalog(resolver)?;
527            catalog_owned.as_dict()?
528        }
529    } else {
530        catalog_owned = find_catalog(resolver)?;
531        catalog_owned.as_dict()?
532    };
533
534    let intents_obj = resolver.deref(catalog_dict.get(b"OutputIntents")?).ok()?;
535    let intents_arr = intents_obj.as_array()?;
536    for entry in intents_arr {
537        let intent = match resolver.deref(entry) {
538            Ok(o) => o,
539            Err(_) => continue,
540        };
541        let Some(intent_dict) = intent.as_dict() else {
542            continue;
543        };
544        let Some(profile_obj) = intent_dict.get(b"DestOutputProfile") else {
545            continue;
546        };
547        let Ok(bytes) = resolver.stream_data_from_obj(profile_obj) else {
548            continue;
549        };
550        // ICC header: color space at offset 16, 'acsp' magic at offset 36.
551        if bytes.len() >= 40 && &bytes[36..40] == b"acsp" && &bytes[16..20] == b"CMYK" {
552            return Some(bytes);
553        }
554    }
555    None
556}
557
558/// Scan all objects to find the real Catalog dict (has /Type /Catalog).
559/// Used when the trailer's /Root points to the wrong object.
560fn find_catalog(resolver: &Resolver) -> Option<PdfObj> {
561    let xref_len = resolver.xref_len();
562    for obj_num in 0..xref_len as u32 {
563        if let Ok(obj) = resolver.resolve(obj_num, 0) {
564            if let Some(dict) = obj.as_dict() {
565                if dict.get_name(b"Type") == Some(b"Catalog") && dict.get(b"Pages").is_some() {
566                    return Some(obj);
567                }
568            }
569        }
570    }
571    None
572}
573
574/// Check for `%PDF-` header within the first 1024 bytes.
575/// The PDF spec (§7.5.2) allows data before the header.
576fn has_pdf_header(data: &[u8]) -> bool {
577    let search_range = data.len().min(1024);
578    data[..search_range].windows(5).any(|w| w == b"%PDF-")
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn not_a_pdf() {
587        let result = PdfDocument::from_bytes(b"not a pdf");
588        assert!(matches!(result, Err(PdfError::NotAPdf)));
589    }
590
591    #[test]
592    fn parse_minimal_pdf() {
593        let pdf = build_minimal_pdf();
594        let doc = PdfDocument::from_bytes(&pdf).unwrap();
595        assert_eq!(doc.page_count(), 1);
596
597        let (w, h) = doc.page_size(0).unwrap();
598        assert_eq!(w, 612.0);
599        assert_eq!(h, 792.0);
600    }
601
602    #[test]
603    fn page_out_of_range() {
604        let pdf = build_minimal_pdf();
605        let doc = PdfDocument::from_bytes(&pdf).unwrap();
606        assert!(matches!(
607            doc.page_size(5),
608            Err(PdfError::PageOutOfRange(5, 1))
609        ));
610    }
611
612    #[test]
613    fn page_contents_empty() {
614        let pdf = build_minimal_pdf();
615        let doc = PdfDocument::from_bytes(&pdf).unwrap();
616        let contents = doc.page_contents(0).unwrap();
617        // Our minimal PDF has no content stream
618        assert!(contents.is_empty());
619    }
620
621    #[test]
622    #[ignore]
623    fn dump_display_list() {
624        use stet_fonts::geometry::PsPath;
625        use stet_graphics::display_list::{DisplayElement, DisplayList};
626
627        fn path_bbox(path: &PsPath) -> String {
628            use stet_fonts::geometry::PathSegment;
629            let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
630            for seg in &path.segments {
631                let pts: Vec<(f64, f64)> = match seg {
632                    PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => vec![(*x, *y)],
633                    PathSegment::CurveTo {
634                        x1,
635                        y1,
636                        x2,
637                        y2,
638                        x3,
639                        y3,
640                    } => vec![(*x1, *y1), (*x2, *y2), (*x3, *y3)],
641                    PathSegment::ClosePath => vec![],
642                };
643                for (px, py) in pts {
644                    x0 = x0.min(px);
645                    y0 = y0.min(py);
646                    x1 = x1.max(px);
647                    y1 = y1.max(py);
648                }
649            }
650            format!("bbox=({:.0},{:.0},{:.0},{:.0})", x0, y0, x1, y1)
651        }
652
653        fn dump(list: &DisplayList, depth: usize) {
654            let indent = "  ".repeat(depth);
655            for (i, elem) in list.elements().iter().enumerate() {
656                match elem {
657                    DisplayElement::Fill { path, params } => {
658                        let c = &params.color;
659                        let cmyk_str = if let Some((c2, m, y, k)) = params.color.native_cmyk {
660                            format!(" cmyk=({:.2},{:.2},{:.2},{:.2})", c2, m, y, k)
661                        } else {
662                            String::new()
663                        };
664                        eprintln!(
665                            "{indent}[{i}] Fill rgb=({:.2},{:.2},{:.2}){} op={} opm={} ch=0x{:x} a={:.2} {}",
666                            c.r,
667                            c.g,
668                            c.b,
669                            cmyk_str,
670                            params.overprint,
671                            params.overprint_mode,
672                            params.painted_channels,
673                            params.alpha,
674                            path_bbox(path)
675                        );
676                    }
677                    DisplayElement::Stroke { path, params } => {
678                        let c = &params.color;
679                        eprintln!(
680                            "{indent}[{i}] Stroke rgb=({:.2},{:.2},{:.2}) {}",
681                            c.r,
682                            c.g,
683                            c.b,
684                            path_bbox(path)
685                        );
686                    }
687                    DisplayElement::Clip { path, .. } => {
688                        eprintln!("{indent}[{i}] Clip {}", path_bbox(path))
689                    }
690                    DisplayElement::InitClip => eprintln!("{indent}[{i}] InitClip"),
691                    DisplayElement::Image { params, .. } => {
692                        eprintln!("{indent}[{i}] Image {}x{}", params.width, params.height);
693                    }
694                    DisplayElement::ErasePage => eprintln!("{indent}[{i}] ErasePage"),
695                    DisplayElement::AxialShading { params } => {
696                        eprintln!(
697                            "{indent}[{i}] AxialShading cs={:?} stops={}",
698                            params.color_space,
699                            params.color_stops.len()
700                        );
701                    }
702                    DisplayElement::RadialShading { params } => {
703                        eprintln!(
704                            "{indent}[{i}] RadialShading cs={:?} stops={} ext=({},{}) c0=({:.1},{:.1}) r0={:.1} c1=({:.1},{:.1}) r1={:.1} bbox={:?} op={} ch=0x{:x}",
705                            params.color_space,
706                            params.color_stops.len(),
707                            params.extend_start,
708                            params.extend_end,
709                            params.x0,
710                            params.y0,
711                            params.r0,
712                            params.x1,
713                            params.y1,
714                            params.r1,
715                            params.bbox,
716                            params.overprint,
717                            params.painted_channels
718                        );
719                        // Print first and last stop
720                        if let Some(first) = params.color_stops.first() {
721                            eprintln!(
722                                "{indent}  stop[0]: pos={:.3} rgb=({:.3},{:.3},{:.3}) raw={:?}",
723                                first.position,
724                                first.color.r,
725                                first.color.g,
726                                first.color.b,
727                                first.raw_components
728                            );
729                        }
730                        if let Some(last) = params.color_stops.last() {
731                            eprintln!(
732                                "{indent}  stop[{}]: pos={:.3} rgb=({:.3},{:.3},{:.3}) raw={:?}",
733                                params.color_stops.len() - 1,
734                                last.position,
735                                last.color.r,
736                                last.color.g,
737                                last.color.b,
738                                last.raw_components
739                            );
740                        }
741                        // Print mid stop
742                        let mid = params.color_stops.len() / 2;
743                        if mid > 0 && mid < params.color_stops.len() - 1 {
744                            let s = &params.color_stops[mid];
745                            eprintln!(
746                                "{indent}  stop[{mid}]: pos={:.3} rgb=({:.3},{:.3},{:.3}) raw={:?}",
747                                s.position, s.color.r, s.color.g, s.color.b, s.raw_components
748                            );
749                        }
750                    }
751                    DisplayElement::MeshShading { .. } => eprintln!("{indent}[{i}] MeshShading"),
752                    DisplayElement::PatchShading { .. } => eprintln!("{indent}[{i}] PatchShading"),
753                    DisplayElement::PatternFill { .. } => eprintln!("{indent}[{i}] PatternFill"),
754                    DisplayElement::Text { .. } => eprintln!("{indent}[{i}] Text"),
755                    DisplayElement::Group { elements, params } => {
756                        eprintln!(
757                            "{indent}[{i}] Group iso={} ko={} blend={} a={:.2} bbox=({:.0},{:.0},{:.0},{:.0}) children={}",
758                            params.isolated,
759                            params.knockout,
760                            params.blend_mode,
761                            params.alpha,
762                            params.bbox[0],
763                            params.bbox[1],
764                            params.bbox[2],
765                            params.bbox[3],
766                            elements.len()
767                        );
768                        dump(elements, depth + 1);
769                    }
770                    DisplayElement::SoftMasked {
771                        mask,
772                        content,
773                        params,
774                        ..
775                    } => {
776                        eprintln!(
777                            "{indent}[{i}] SoftMasked {:?} mask={} content={}",
778                            params.subtype,
779                            mask.len(),
780                            content.len()
781                        );
782                        eprintln!("{indent}  MASK:");
783                        dump(mask, depth + 2);
784                        eprintln!("{indent}  CONTENT:");
785                        dump(content, depth + 2);
786                    }
787                    DisplayElement::OcgGroup {
788                        elements,
789                        ocg_id,
790                        default_visible,
791                    } => {
792                        eprintln!(
793                            "{indent}[{i}] OcgGroup id={} visible={} children={}",
794                            ocg_id,
795                            default_visible,
796                            elements.len()
797                        );
798                        dump(elements, depth + 1);
799                    }
800                }
801            }
802        }
803
804        let data = std::fs::read("../../pdf_samples/PDFX-ready_Output-Test_X4.pdf").unwrap();
805        let doc = PdfDocument::from_bytes(&data).unwrap();
806        let dl = doc.render_page(0, 72.0).unwrap();
807        eprintln!("=== Display list: {} top-level elements ===", dl.len());
808        dump(&dl, 0);
809    }
810
811    /// Build a minimal valid PDF for testing.
812    fn build_minimal_pdf() -> Vec<u8> {
813        let mut pdf = Vec::new();
814        pdf.extend(b"%PDF-1.4\n");
815
816        // Object 1: Catalog
817        let obj1_offset = pdf.len();
818        pdf.extend(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
819
820        // Object 2: Pages
821        let obj2_offset = pdf.len();
822        pdf.extend(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
823
824        // Object 3: Page
825        let obj3_offset = pdf.len();
826        pdf.extend(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n");
827
828        // Xref
829        let xref_offset = pdf.len();
830        pdf.extend(b"xref\n0 4\n");
831        pdf.extend(b"0000000000 65535 f\r\n");
832        pdf.extend(format!("{:010} 00000 n\r\n", obj1_offset).as_bytes());
833        pdf.extend(format!("{:010} 00000 n\r\n", obj2_offset).as_bytes());
834        pdf.extend(format!("{:010} 00000 n\r\n", obj3_offset).as_bytes());
835        pdf.extend(b"trailer\n<< /Size 4 /Root 1 0 R >>\n");
836        pdf.extend(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
837
838        pdf
839    }
840}