Skip to main content

stet_pdf_reader/content/
mod.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! PDF content stream interpreter.
6//!
7//! Converts PDF page content into a `DisplayList` for rendering through
8//! the existing SkiaDevice pipeline.
9
10pub mod cid_unicode;
11pub mod cmap;
12pub mod color_space;
13pub mod font;
14mod gid_maps;
15pub mod graphics_state;
16mod standard_fonts;
17
18use crate::error::PdfError;
19use crate::lexer::{Lexer, MAX_OBJECT_DEPTH, Token};
20use crate::objects::{PdfDict, PdfObj};
21use crate::resolver::Resolver;
22
23use self::color_space::{
24    ResolvedColorSpace, painted_channels_for_cs, register_icc_profile, resolve_color_space,
25    resolve_color_space_obj, to_image_color_space,
26};
27use self::graphics_state::{ColorSpaceRef, PdfGraphicsState};
28
29use std::sync::{Arc, Mutex};
30
31use self::font::{FontCache, PdfFont};
32use self::graphics_state::{ShadingPatternDL, TilingPattern};
33use crate::FontProvider;
34use stet_fonts::geometry::{Matrix, PathSegment, PsPath};
35use stet_graphics::color::{DashPattern, DeviceColor, FillRule, LineCap, LineJoin};
36use stet_graphics::device::{
37    ClipParams, FillParams, ImageColorSpace, ImageParams, PatternFillParams, StrokeParams,
38    TintLookupTable,
39};
40use stet_graphics::display_list::{
41    DisplayElement, DisplayList, GroupParams, OcgVisibility, SoftMaskParams, SoftMaskSubtype,
42};
43use stet_graphics::icc::IccCache;
44use stet_graphics::image_limits::{
45    validate_bits_per_component, validate_image_dimension, validate_image_size,
46};
47
48/// Maximum nesting of re-entrant content streams.
49///
50/// Form XObjects, tiling and shading patterns, Type 3 CharProcs, and soft-mask
51/// groups are all interpreted by re-entering `interpret_stream`, and each can
52/// name itself either directly or through a ring of siblings. A cycle recurses
53/// on the native stack, which overflows into an abort that `catch_unwind`
54/// cannot contain — so every such entry point checks this cap. Legitimate
55/// documents nest a handful of levels; 20 was already the Form XObject limit
56/// and is kept here so the bound does not change for files that render today.
57const MAX_CONTENT_NESTING: u32 = 20;
58
59/// One entry on the marked-content stack. Pushed on every BDC/BMC, popped
60/// on every EMC — so the stack stays balanced regardless of how OC and
61/// non-OC blocks interleave. Only `Ocg` entries swap the display list;
62/// `Other` entries carry no state but must still exist so EMC pops the
63/// correct frame.
64enum MarkedContentFrame {
65    /// An `/OC BDC` block: parent display list was swapped out, and the
66    /// matching EMC wraps the collected children in an `OcgGroup`.
67    Ocg {
68        parent_list: DisplayList,
69        visibility: OcgVisibility,
70    },
71    /// Any other BDC (non-OC property reference) or BMC.
72    Other,
73}
74
75/// Read a numeric array entry, following an indirect reference if needed.
76///
77/// PDF dict values like `/BBox`, `/Matrix`, `/MediaBox`, etc. are commonly
78/// shared across multiple objects via indirect references (e.g. several form
79/// XObjects may share a single `/BBox 4 0 R`). `PdfDict::get_array` returns
80/// `None` for an indirect-ref value, so callers that need the numbers must
81/// dereference first.
82fn deref_num_array(resolver: &Resolver, dict: &PdfDict, key: &[u8]) -> Option<Vec<f64>> {
83    let obj = dict.get(key)?;
84    if let Some(arr) = obj.as_array() {
85        return Some(arr.iter().filter_map(|o| o.as_f64()).collect());
86    }
87    let resolved = resolver.deref(obj).ok()?;
88    resolved
89        .as_array()
90        .map(|a| a.iter().filter_map(|o| o.as_f64()).collect())
91}
92
93/// An operand on the content stream operand stack.
94#[derive(Clone, Debug)]
95pub enum Operand {
96    Int(i64),
97    Real(f64),
98    Name(Vec<u8>),
99    Str(Vec<u8>),
100    Array(Vec<PdfObj>),
101    Dict(PdfDict),
102    Bool(bool),
103}
104
105impl Operand {
106    /// Get numeric value as f64.
107    fn as_f64(&self) -> Option<f64> {
108        match self {
109            Operand::Int(n) => Some(*n as f64),
110            Operand::Real(f) => Some(*f),
111            _ => None,
112        }
113    }
114
115    /// Get name bytes.
116    fn as_name(&self) -> Option<&[u8]> {
117        match self {
118            Operand::Name(n) => Some(n),
119            _ => None,
120        }
121    }
122
123    /// Get string bytes.
124    #[allow(dead_code)]
125    fn as_str(&self) -> Option<&[u8]> {
126        match self {
127            Operand::Str(s) => Some(s),
128            _ => None,
129        }
130    }
131}
132
133/// Cached result of a fully-processed Image XObject.
134/// Keyed by obj_num in the content interpreter's `image_cache`.
135#[derive(Clone)]
136struct CachedImage {
137    sample_data: Arc<Vec<u8>>,
138    width: u32,
139    height: u32,
140    color_space: ImageColorSpace,
141    bits_per_component: u8,
142    interpolate: bool,
143    mask_color: Option<Vec<u8>>,
144    /// CMYK painted channels derived from the image's own color space.
145    painted_channels: u8,
146    /// For soft-masked images: (mask_gray_data, mask_width, mask_height, matte).
147    smask: Option<(Arc<Vec<u8>>, u32, u32, Option<Vec<f64>>)>,
148    /// The image's `/Intent` (or the gstate `/RI` at first emit if absent).
149    /// Cached so re-emits keep the same intent — the image-level intent is a
150    /// property of the image, not of the gstate at re-use time.
151    rendering_intent: u8,
152}
153
154/// Tracks the scope of an active soft mask in the display list.
155struct SoftMaskScope {
156    /// Index in display_list where the mask scope began.
157    start_index: usize,
158    /// The resolved soft mask (mask display list + params).
159    mask: graphics_state::SoftMask,
160}
161
162/// PDF content stream interpreter.
163pub struct ContentInterpreter<'a> {
164    resolver: &'a Resolver<'a>,
165    resources: PdfDict,
166    gstate_stack: Vec<PdfGraphicsState>,
167    gstate: PdfGraphicsState,
168    current_path: PsPath,
169    current_point: Option<(f64, f64)>,
170    subpath_start: Option<(f64, f64)>,
171    operand_stack: Vec<Operand>,
172    display_list: DisplayList,
173    in_text: bool,
174    /// Nesting level of the content stream being interpreted: Form XObjects,
175    /// tiling/shading patterns, Type 3 CharProcs, soft-mask forms, and
176    /// annotation appearance streams each add one. Capped at
177    /// [`MAX_CONTENT_NESTING`] — every one of those constructs can name itself
178    /// (directly or through a ring), and unguarded re-entry aborts the process
179    /// with a stack overflow rather than a catchable panic.
180    depth: u32,
181    /// True inside a Type 3 CharProc that started with `d1`. Per PDF spec 9.6.5,
182    /// color operators must be ignored (glyph uses the current text color).
183    d1_color_suppressed: bool,
184    font_cache: FontCache,
185    current_font: Option<Arc<PdfFont>>,
186    /// CTM at the start of the current content stream (page or form).
187    /// PDF pattern Matrix maps to the "default (initial) coordinate system
188    /// of the parent content stream" — for patterns inside Form XObjects,
189    /// this includes the form's matrix transform, not just the page CTM.
190    content_stream_ctm: Matrix,
191    /// The initial page CTM (DPI scaling + Y-flip + CropBox offset).
192    /// Never modified after construction. Used by annotation rendering to
193    /// position appearance streams relative to the page, not the CTM left
194    /// behind by the content stream.
195    initial_ctm: Matrix,
196    /// ICC color profile cache for ICCBased color space conversions.
197    icc_cache: IccCache,
198    /// Active soft mask scope: tracks which display list elements fall under the current SMask.
199    soft_mask_scope: Option<SoftMaskScope>,
200    /// Counter: incremented when the Q handler flushes a gs-set SMask scope
201    /// (detected via smask_gen change). Used by resolve_soft_mask to detect
202    /// genuine nested mask scopes vs image-level SMasks.
203    nested_mask_flush_count: u32,
204    /// Optional font data provider for environments without filesystem access.
205    font_provider: Option<FontProvider>,
206    /// Accumulated text clip path (for text rendering modes 4-7).
207    /// Built up during BT..ET, applied as clip at ET.
208    text_clip_path: Option<PsPath>,
209    /// When true, DeviceRGB colors are round-tripped through the system CMYK
210    /// profile (RGB→CMYK→RGB) to match compositing in a DeviceCMYK page group.
211    page_group_is_cmyk: bool,
212    /// True only when the document declares a PDF/X-style CMYK output intent
213    /// (`/OutputIntents` with a CMYK profile). Required (along with
214    /// `page_group_is_cmyk`) for the DeviceGray-to-K-only promotion to fire.
215    ///
216    /// Plain `/Group /CS /DeviceCMYK` without an output intent (e.g.
217    /// `pdf_samples/3000_5.pdf`, `pdf_samples/2495.pdf`) signals only that
218    /// compositing happens in CMYK space — it does NOT opt the document into
219    /// the system CMYK profile's paper white, so a `0.5 g` paint must still
220    /// render at exact RGB(128, 128, 128). Documents that DO declare a CMYK
221    /// output intent (PDF/X) accept the profile's paper white as their own
222    /// and expect DeviceGray to map onto the K plate (GWG 23.0). The narrower
223    /// gate matches the existing `cmyk_group_rgb` round-trip's expectation:
224    /// that helper takes RGB and rounds it through CMYK→RGB (range-preserving
225    /// on a non-PDF/X CMYK group), while this gate switches the source space
226    /// from DeviceGray to DeviceCMYK — only correct when the document opts in.
227    pdfx_cmyk_intent: bool,
228    /// True while parsing an SMask source form's content stream. The
229    /// DeviceGray-to-K-only promotion (`gray_paint_for_gstate`,
230    /// `cmyk_group_promote_image`, `cmyk_group_promote_color`) must be
231    /// suppressed in this context: image color conversion happens at render
232    /// time, after the parse-time `suspend_default_cmyk` has been restored,
233    /// so a promoted CMYK-K image emitted from an SMask source would later
234    /// hit the ICC pipeline and shift the SMask's luminosity (g=255 → CMYK
235    /// 0/0/0/0 → ICC RGB ≈ (241,241,241) → alpha ≈ 0.94 instead of 1.0,
236    /// regressed `pdf_samples/2495.pdf`'s right-side images). Mirrors the
237    /// existing `IccCache::suspend_default_cmyk` precedent at the layer where
238    /// the promotion happens — needed even within PDF/X documents (the gate
239    /// above doesn't help there; only the SMask context guard does).
240    in_smask_form: bool,
241    /// When false, PDF overprint flags (OP/op) in graphics state dicts are
242    /// suppressed — the gstate overprint fields stay false regardless of PDF content.
243    overprint_enabled: bool,
244    /// Cache of resolved tiling patterns, keyed by PDF indirect reference (obj_num, gen).
245    /// Ensures the same pattern stream is interpreted only once, with the graphics
246    /// state from the first resolution (matching GhostScript behaviour).
247    pattern_cache: std::collections::HashMap<(u32, u16), TilingPattern>,
248    /// Object numbers of Optional Content Groups that are OFF by default.
249    ocg_off: std::collections::HashSet<u32>,
250    /// Stack of marked-content frames. Every BDC/BMC pushes one frame,
251    /// every EMC pops one — keeping the stack balanced even when OC and
252    /// non-OC sections interleave. Only `Ocg` frames actually swap the
253    /// display list; `Other` frames are placeholders.
254    mc_stack: Vec<MarkedContentFrame>,
255    /// HashMap index of the current resource dict's ColorSpace sub-dict.
256    /// Built lazily on first sc/scn access to avoid O(n) PdfDict scans.
257    cs_index: Option<std::collections::HashMap<Vec<u8>, PdfObj>>,
258    /// Visible Y range in form coordinates for early culling of offscreen content.
259    /// Set when entering a Form XObject whose BBox is much larger than the clip area.
260    /// BT/ET blocks whose Y position falls outside this range are skipped.
261    form_cull_y: Option<(f64, f64)>,
262    /// When true, the current BT/ET block is being skipped (offscreen).
263    bt_culled: bool,
264    /// Cache of fully-processed Image XObject data, keyed by obj_num.
265    /// Avoids re-decompressing and re-converting the same image (e.g.,
266    /// Type 3 emoji glyphs that reference the same XObject 73 times).
267    image_cache: std::collections::HashMap<u32, CachedImage>,
268    /// Cache of pre-sampled spot tint tables, keyed by the colorspace's
269    /// canonical identity (Separation name or DeviceN colorant list). The
270    /// tint function is expensive to sample (up to 256 evaluations for
271    /// Separation, thousands for DeviceN), and many documents paint the same
272    /// spot color hundreds of times per page; without this cache every
273    /// `sc`/`scn` re-evaluates the same function from scratch.
274    spot_tint_table_cache:
275        std::collections::HashMap<Vec<u8>, Arc<stet_graphics::device::TintLookupTable>>,
276}
277
278impl<'a> ContentInterpreter<'a> {
279    /// Create a new interpreter.
280    pub fn new(
281        resolver: &'a Resolver<'a>,
282        resources: PdfDict,
283        initial_ctm: Matrix,
284        icc_cache: &IccCache,
285        font_provider: Option<FontProvider>,
286        overprint_enabled: bool,
287        ocg_off: &std::collections::HashSet<u32>,
288    ) -> Self {
289        Self {
290            resolver,
291            resources,
292            gstate_stack: Vec::new(),
293            gstate: PdfGraphicsState::new(initial_ctm),
294            current_path: PsPath::new(),
295            current_point: None,
296            subpath_start: None,
297            operand_stack: Vec::new(),
298            display_list: DisplayList::new(),
299            content_stream_ctm: initial_ctm,
300            initial_ctm,
301            in_text: false,
302            depth: 0,
303            d1_color_suppressed: false,
304            nested_mask_flush_count: 0,
305            font_cache: FontCache::new(),
306            current_font: None,
307            icc_cache: icc_cache.clone(),
308            soft_mask_scope: None,
309            font_provider,
310            text_clip_path: None,
311            page_group_is_cmyk: false,
312            pdfx_cmyk_intent: false,
313            in_smask_form: false,
314            overprint_enabled,
315            pattern_cache: std::collections::HashMap::new(),
316            ocg_off: ocg_off.clone(),
317            mc_stack: Vec::new(),
318            cs_index: None,
319            form_cull_y: None,
320            bt_culled: false,
321            image_cache: std::collections::HashMap::new(),
322            spot_tint_table_cache: std::collections::HashMap::new(),
323        }
324    }
325
326    /// Mark this page as having a DeviceCMYK transparency group.
327    /// DeviceRGB colors will be round-tripped through the ICC CMYK profile
328    /// to match compositing in CMYK space (produces more muted, accurate colors).
329    pub fn set_page_group_cmyk(&mut self) {
330        self.page_group_is_cmyk = true;
331    }
332
333    /// Mark this document as declaring a PDF/X-style CMYK output intent. Opts
334    /// the document into the DeviceGray-to-K-only promotion required by GWG
335    /// 23.0. Plain `/Group /CS /DeviceCMYK` pages should not call this.
336    pub fn set_pdfx_cmyk_intent(&mut self) {
337        self.pdfx_cmyk_intent = true;
338    }
339
340    /// Look up a sub-dictionary in the resources, resolving indirect references.
341    /// e.g., `resolve_resource_subdict(b"Font")` returns the /Font dict.
342    /// Get an integer value from a dict, resolving indirect references.
343    fn resolve_dict_int(&self, dict: &PdfDict, key: &[u8]) -> Option<i64> {
344        let obj = dict.get(key)?;
345        if let Some(n) = obj.as_int() {
346            return Some(n);
347        }
348        // Resolve indirect reference
349        let resolved = self.resolver.deref(obj).ok()?;
350        resolved.as_int()
351    }
352
353    fn resolve_resource_subdict(&self, key: &[u8]) -> Option<PdfDict> {
354        let obj = self.resources.get(key)?;
355        // If it's already a dict, return it
356        if let Some(d) = obj.as_dict() {
357            return Some(d.clone());
358        }
359        // Otherwise try to resolve the reference
360        let resolved = self.resolver.deref(obj).ok()?;
361        resolved.as_dict().cloned()
362    }
363
364    /// Interpret a content stream and return the display list.
365    pub fn interpret(mut self, data: &[u8]) -> Result<DisplayList, PdfError> {
366        if let Err(e) = self.interpret_stream(data) {
367            eprintln!("warning: content stream error: {}", e);
368        }
369        // Flush any active soft mask scope
370        self.flush_soft_mask();
371        // Return partial display list even on error — handles malformed PDFs
372        // where flate decompression produces truncated content streams.
373        Ok(self.display_list)
374    }
375
376    /// Interpret a content stream, keeping the interpreter alive for further use.
377    pub fn interpret_stream_public(&mut self, data: &[u8]) -> Result<(), PdfError> {
378        self.interpret_stream(data)
379    }
380
381    /// Consume the interpreter and return the display list.
382    pub fn into_display_list(mut self) -> DisplayList {
383        self.flush_soft_mask();
384        // Close any unmatched marked-content blocks from unbalanced BDC/EMC
385        while let Some(frame) = self.mc_stack.pop() {
386            if let MarkedContentFrame::Ocg {
387                parent_list,
388                visibility,
389            } = frame
390            {
391                let ocg_list = std::mem::replace(&mut self.display_list, parent_list);
392                self.display_list.push(DisplayElement::OcgGroup {
393                    elements: ocg_list,
394                    visibility,
395                });
396            }
397        }
398        self.display_list
399    }
400
401    /// Unwind any leftover gstate stack entries from unbalanced q/Q.
402    /// Some PDFs have more q's than Q's; pop each entry as if Q were called,
403    /// restoring clip state at each level so display list clips are correct.
404    pub fn unwind_gstate_stack(&mut self) {
405        while let Some(saved) = self.gstate_stack.pop() {
406            let old_clip_version = self.gstate.clip_path_version;
407            self.gstate = saved;
408            if self.gstate.clip_path_version != old_clip_version {
409                self.restore_clip_from_stack();
410            }
411        }
412    }
413
414    /// Reset clip state before rendering annotations.
415    /// This ensures annotations aren't affected by clip regions from the page content.
416    pub fn reset_clip_for_annotations(&mut self) {
417        self.display_list.push(DisplayElement::InitClip);
418        self.gstate.clip_path = None;
419        self.gstate.clip_stack.clear();
420        self.gstate.clip_path_version += 1;
421    }
422
423    /// Render an annotation's normal appearance stream (/AP /N).
424    pub fn render_annotation(&mut self, obj_num: u32, gen_num: u16) -> Result<(), PdfError> {
425        let annot_obj = self.resolver.resolve(obj_num, gen_num)?;
426        let annot_dict = annot_obj
427            .as_dict()
428            .ok_or(PdfError::Other("annotation not a dict".into()))?;
429
430        let subtype = annot_dict.get_name(b"Subtype").unwrap_or(b"");
431
432        // Check annotation flags (/F). PDF spec Table 165:
433        // Bit 1 (0x01) = Invisible, Bit 2 (0x02) = Hidden, Bit 6 (0x20) = NoView.
434        // Skip annotations that shouldn't be rendered on screen.
435        let flags = annot_dict.get_int(b"F").unwrap_or(0);
436        if flags & 0x02 != 0 {
437            return Ok(()); // Hidden
438        }
439
440        // Get /Rect [llx, lly, urx, ury], normalizing swapped coordinates.
441        // Rect may be an indirect reference — resolve before parsing.
442        let rect = annot_dict
443            .get(b"Rect")
444            .and_then(|obj| {
445                let resolved = self.resolver.deref(obj).ok().unwrap_or(obj.clone());
446                let a = resolved
447                    .as_array()
448                    .or_else(|| annot_dict.get_array(b"Rect"))?;
449                if a.len() >= 4 {
450                    let r0 = a[0].as_f64()?;
451                    let r1 = a[1].as_f64()?;
452                    let r2 = a[2].as_f64()?;
453                    let r3 = a[3].as_f64()?;
454                    Some([r0.min(r2), r1.min(r3), r0.max(r2), r1.max(r3)])
455                } else {
456                    None
457                }
458            })
459            .ok_or(PdfError::Other("annotation missing Rect".into()))?;
460
461        // Get /AP dict → /N (normal appearance).
462        // If no AP, synthesize appearance from annotation properties.
463        let ap_obj = match annot_dict.get(b"AP") {
464            Some(ap) => ap,
465            None => {
466                return self.synthesize_annotation(annot_dict, &rect);
467            }
468        };
469        let ap_dict = match self.resolver.deref(ap_obj)? {
470            PdfObj::Dict(d) => d,
471            _ => return Err(PdfError::Other("AP not a dict".into())),
472        };
473
474        let n_ref = ap_dict.get(b"N").ok_or(PdfError::Other("no AP/N".into()))?;
475
476        // Resolve to get the Form XObject dict + stream.
477        // AP/N may be a stream (single appearance) or a dict mapping state
478        // names to streams (e.g. checkboxes: << /Yes stream /Off stream >>).
479        let n_obj = self.resolver.deref(n_ref)?;
480        let (n_ref, form_dict) = if let Some(d) = n_obj.as_dict() {
481            if d.get(b"BBox").is_some() {
482                // It's a Form XObject stream dict
483                (n_ref.clone(), d.clone())
484            } else {
485                // State-specific appearance dict: pick the entry matching /AS.
486                // For Widget annotations (checkboxes/radios), /AS determines
487                // which appearance to show (e.g. /Yes = checked, /Off = unchecked).
488                // When /AS is absent, default to /Off (unchecked). Only fall back
489                // to the first entry for non-Widget annotations.
490                let as_name = annot_dict.get_name(b"AS").unwrap_or(b"Off");
491                let state_ref = match d.get(as_name) {
492                    Some(r) => r,
493                    None if subtype == b"Widget" => {
494                        // Widget with no matching state — skip rendering
495                        return Ok(());
496                    }
497                    None => {
498                        // Non-widget: try the first entry as fallback
499                        match d.entries().first().map(|(_, v)| v) {
500                            Some(r) => r,
501                            None => return Ok(()),
502                        }
503                    }
504                };
505                let state_obj = self.resolver.deref(state_ref)?;
506                let state_dict = state_obj
507                    .as_dict()
508                    .ok_or(PdfError::Other("AP/N state not a stream".into()))?;
509                (state_ref.clone(), state_dict.clone())
510            }
511        } else {
512            return Err(PdfError::Other("AP/N not a dict or stream".into()));
513        };
514
515        // The appearance stream is a Form XObject. Its BBox defines the
516        // coordinate space, and we need to map it to the annotation Rect.
517        // BBox may be an indirect reference — resolve before accessing.
518        let bbox = form_dict
519            .get(b"BBox")
520            .and_then(|obj| {
521                let resolved = self.resolver.deref(obj).ok().unwrap_or(obj.clone());
522                let a = resolved.as_array()?;
523                if a.len() >= 4 {
524                    Some([
525                        a[0].as_f64()?,
526                        a[1].as_f64()?,
527                        a[2].as_f64()?,
528                        a[3].as_f64()?,
529                    ])
530                } else {
531                    None
532                }
533            })
534            .unwrap_or([rect[0], rect[1], rect[2], rect[3]]);
535
536        // Apply form's own matrix if present
537        let form_matrix = deref_num_array(self.resolver, &form_dict, b"Matrix")
538            .and_then(|v| {
539                if v.len() == 6 {
540                    Some(Matrix::new(v[0], v[1], v[2], v[3], v[4], v[5]))
541                } else {
542                    None
543                }
544            })
545            .unwrap_or_else(Matrix::identity);
546
547        // Build transform: map the Matrix-transformed BBox → Rect.
548        // Per PDF spec 12.5.5, the form's Matrix transforms the BBox into the
549        // coordinate system where the appearance was authored. We need to map
550        // that transformed extent to the annotation's Rect on the page.
551        let (tb0x, tb0y) = form_matrix.transform_point(bbox[0], bbox[1]);
552        let (tb1x, tb1y) = form_matrix.transform_point(bbox[2], bbox[3]);
553        let tbbox_w = (tb1x - tb0x).abs().max(0.001);
554        let tbbox_h = (tb1y - tb0y).abs().max(0.001);
555        let rect_w = (rect[2] - rect[0]).abs();
556        let rect_h = (rect[3] - rect[1]).abs();
557        let sx = rect_w / tbbox_w;
558        let sy = rect_h / tbbox_h;
559        let tx = rect[0] - tb0x.min(tb1x) * sx;
560        let ty = rect[1] - tb0y.min(tb1y) * sy;
561        let bbox_to_rect = Matrix::new(sx, 0.0, 0.0, sy, tx, ty);
562
563        // Render as a Form XObject with the computed transform
564        let saved_gstate = self.gstate.clone();
565        let saved_stack_depth = self.gstate_stack.len();
566        let saved_resources = self.resources.clone();
567        let saved_mc_stack = std::mem::take(&mut self.mc_stack);
568        // Set up resources from the form
569        if let Some(res_obj) = form_dict.get(b"Resources")
570            && let Ok(PdfObj::Dict(d)) = self.resolver.deref(res_obj)
571        {
572            self.resources = d;
573        }
574
575        // Apply CTM: initial page CTM → bbox_to_rect → form_matrix
576        // Use the initial page CTM (not the post-content-stream CTM) because
577        // annotation Rects are in page coordinates, not in whatever coordinate
578        // system the content stream left behind after cm operations.
579        self.gstate.ctm = self.initial_ctm.concat(&bbox_to_rect).concat(&form_matrix);
580
581        // Update content_stream_ctm so shading patterns inside the annotation
582        // use the annotation's coordinate system (not the page's).
583        let saved_content_stream_ctm = self.content_stream_ctm;
584        self.content_stream_ctm = self.gstate.ctm;
585
586        // Note: no BBox clip here — appearance streams do their own internal clipping.
587
588        // Interpret the form content
589        let form_data = self.resolver.stream_data_from_obj(&n_ref)?;
590        self.depth += 1;
591        let _ = self.interpret_stream(&form_data);
592        self.depth -= 1;
593
594        // Restore state — truncate gstate stack to handle unbalanced q/Q in stream
595        self.gstate_stack.truncate(saved_stack_depth);
596        self.content_stream_ctm = saved_content_stream_ctm;
597        self.resources = saved_resources;
598        self.mc_stack = saved_mc_stack;
599        self.gstate = saved_gstate;
600
601        // Restore clip in display list (annotation may have modified clip state)
602        self.display_list.push(DisplayElement::InitClip);
603        if let Some(ref clip) = self.gstate.clip_path {
604            self.display_list.push(DisplayElement::Clip {
605                path: clip.clone(),
606                params: ClipParams {
607                    fill_rule: FillRule::NonZeroWinding,
608                    ctm: Matrix::identity(),
609                    stroke_params: None,
610                },
611            });
612        }
613
614        Ok(())
615    }
616
617    /// Synthesize an appearance for annotations that lack an /AP stream.
618    /// Handles Line, PolyLine, Ink, Highlight, StrikeOut, Underline, and Squiggly.
619    fn synthesize_annotation(
620        &mut self,
621        dict: &crate::objects::PdfDict,
622        rect: &[f64; 4],
623    ) -> Result<(), PdfError> {
624        let subtype = dict.get_name(b"Subtype").unwrap_or(b"");
625
626        // Extract annotation color (/C array, default black)
627        let color = if let Some(c) = dict.get_array(b"C") {
628            let vals: Vec<f64> = c.iter().filter_map(|o| o.as_f64()).collect();
629            match vals.len() {
630                1 => DeviceColor::from_gray(vals[0]),
631                3 => DeviceColor::from_rgb(vals[0], vals[1], vals[2]),
632                4 => DeviceColor::from_cmyk(vals[0], vals[1], vals[2], vals[3]),
633                _ => DeviceColor::from_gray(0.0),
634            }
635        } else {
636            DeviceColor::from_gray(0.0)
637        };
638
639        // Opacity
640        let alpha = dict.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0);
641
642        // Border width: prefer /BS dict /W, then /Border array [h_radius v_radius width]
643        let border_width = dict
644            .get(b"BS")
645            .and_then(|bs| self.resolver.deref(bs).ok())
646            .and_then(|bs| bs.as_dict().and_then(|d| d.get_f64(b"W")))
647            .or_else(|| {
648                dict.get_array(b"Border")
649                    .and_then(|arr| arr.get(2).and_then(|o| o.as_f64()))
650            })
651            .unwrap_or(1.0);
652
653        // Dash pattern from /BS /D
654        let dash = dict
655            .get(b"BS")
656            .and_then(|bs| self.resolver.deref(bs).ok())
657            .and_then(|bs| {
658                let d = bs.as_dict()?;
659                let style = d.get_name(b"S")?;
660                if style == b"D" {
661                    let arr = d
662                        .get_array(b"D")
663                        .map(|a| a.iter().filter_map(|o| o.as_f64()).collect::<Vec<_>>())
664                        .unwrap_or_else(|| vec![3.0]);
665                    Some(DashPattern {
666                        array: arr,
667                        offset: 0.0,
668                    })
669                } else {
670                    None
671                }
672            })
673            .unwrap_or_default();
674
675        let ctm = self.initial_ctm;
676
677        match subtype {
678            b"Line" => {
679                // /L [x1 y1 x2 y2]
680                if let Some(l) = dict.get_array(b"L") {
681                    let coords: Vec<f64> = l.iter().filter_map(|o| o.as_f64()).collect();
682                    if coords.len() >= 4 {
683                        let (x1, y1, x2, y2) = (coords[0], coords[1], coords[2], coords[3]);
684                        let path = PsPath {
685                            segments: vec![
686                                PathSegment::MoveTo(x1, y1),
687                                PathSegment::LineTo(x2, y2),
688                            ],
689                        };
690                        self.display_list.push(DisplayElement::Stroke {
691                            path,
692                            params: StrokeParams {
693                                color: color.clone(),
694                                line_width: border_width,
695                                line_cap: LineCap::Butt,
696                                line_join: LineJoin::Miter,
697                                miter_limit: 10.0,
698                                dash_pattern: dash.clone(),
699                                ctm,
700                                stroke_adjust: false,
701                                is_text_glyph: false,
702                                overprint: false,
703                                overprint_mode: 0,
704                                opm_paired: false,
705                                painted_channels: 0,
706                                is_device_cmyk: false,
707                                spot_color: None,
708                                icc_color: None,
709                                rendering_intent: 0,
710                                transfer: Default::default(),
711                                halftone: Default::default(),
712                                bg_ucr: Default::default(),
713                                alpha,
714                                blend_mode: 0,
715                                alpha_is_shape: false,
716                            },
717                        });
718                    }
719                }
720            }
721            b"PolyLine" | b"Polygon" => {
722                if let Some(verts) = dict.get_array(b"Vertices") {
723                    let coords: Vec<f64> = verts.iter().filter_map(|o| o.as_f64()).collect();
724                    if coords.len() >= 4 {
725                        let mut segs = vec![PathSegment::MoveTo(coords[0], coords[1])];
726                        for pair in coords[2..].chunks_exact(2) {
727                            segs.push(PathSegment::LineTo(pair[0], pair[1]));
728                        }
729                        if subtype == b"Polygon" {
730                            segs.push(PathSegment::ClosePath);
731                        }
732                        let path = PsPath { segments: segs };
733                        self.display_list.push(DisplayElement::Stroke {
734                            path,
735                            params: StrokeParams {
736                                color: color.clone(),
737                                line_width: border_width,
738                                line_cap: LineCap::Butt,
739                                line_join: LineJoin::Miter,
740                                miter_limit: 10.0,
741                                dash_pattern: dash.clone(),
742                                ctm,
743                                stroke_adjust: false,
744                                is_text_glyph: false,
745                                overprint: false,
746                                overprint_mode: 0,
747                                opm_paired: false,
748                                painted_channels: 0,
749                                is_device_cmyk: false,
750                                spot_color: None,
751                                icc_color: None,
752                                rendering_intent: 0,
753                                transfer: Default::default(),
754                                halftone: Default::default(),
755                                bg_ucr: Default::default(),
756                                alpha,
757                                blend_mode: 0,
758                                alpha_is_shape: false,
759                            },
760                        });
761                    }
762                }
763            }
764            b"Ink" => {
765                if let Some(ink_list) = dict.get_array(b"InkList") {
766                    for stroke_obj in ink_list {
767                        let stroke_arr = match stroke_obj {
768                            crate::objects::PdfObj::Array(a) => a,
769                            _ => continue,
770                        };
771                        let coords: Vec<f64> =
772                            stroke_arr.iter().filter_map(|o| o.as_f64()).collect();
773                        if coords.len() >= 4 {
774                            let mut segs = vec![PathSegment::MoveTo(coords[0], coords[1])];
775                            for pair in coords[2..].chunks_exact(2) {
776                                segs.push(PathSegment::LineTo(pair[0], pair[1]));
777                            }
778                            let path = PsPath { segments: segs };
779                            self.display_list.push(DisplayElement::Stroke {
780                                path,
781                                params: StrokeParams {
782                                    color: color.clone(),
783                                    line_width: border_width,
784                                    line_cap: LineCap::Round,
785                                    line_join: LineJoin::Round,
786                                    miter_limit: 10.0,
787                                    dash_pattern: DashPattern::default(),
788                                    ctm,
789                                    stroke_adjust: false,
790                                    is_text_glyph: false,
791                                    overprint: false,
792                                    overprint_mode: 0,
793                                    opm_paired: false,
794                                    painted_channels: 0,
795                                    is_device_cmyk: false,
796                                    spot_color: None,
797                                    icc_color: None,
798                                    rendering_intent: 0,
799                                    transfer: Default::default(),
800                                    halftone: Default::default(),
801                                    bg_ucr: Default::default(),
802                                    alpha,
803                                    blend_mode: 0,
804                                    alpha_is_shape: false,
805                                },
806                            });
807                        }
808                    }
809                }
810            }
811            b"Highlight" | b"StrikeOut" | b"Underline" | b"Squiggly" => {
812                if let Some(qp) = dict.get_array(b"QuadPoints") {
813                    let pts: Vec<f64> = qp.iter().filter_map(|o| o.as_f64()).collect();
814                    // QuadPoints: groups of 8 (x1,y1, x2,y2, x3,y3, x4,y4)
815                    // Order: top-left, top-right, bottom-left, bottom-right
816                    for quad in pts.chunks_exact(8) {
817                        let (x1, y1) = (quad[0], quad[1]); // top-left
818                        let (x2, y2) = (quad[2], quad[3]); // top-right
819                        let (x3, y3) = (quad[4], quad[5]); // bottom-left
820                        let (x4, y4) = (quad[6], quad[7]); // bottom-right
821
822                        if subtype == b"Highlight" {
823                            // Fill the quad with translucent color
824                            let path = PsPath {
825                                segments: vec![
826                                    PathSegment::MoveTo(x1, y1),
827                                    PathSegment::LineTo(x2, y2),
828                                    PathSegment::LineTo(x4, y4),
829                                    PathSegment::LineTo(x3, y3),
830                                    PathSegment::ClosePath,
831                                ],
832                            };
833                            self.display_list.push(DisplayElement::Fill {
834                                path,
835                                params: FillParams {
836                                    color: color.clone(),
837                                    fill_rule: FillRule::NonZeroWinding,
838                                    ctm,
839                                    is_text_glyph: false,
840                                    overprint: false,
841                                    overprint_mode: 0,
842                                    opm_paired: false,
843                                    painted_channels: 0,
844                                    is_device_cmyk: false,
845                                    spot_color: None,
846                                    icc_color: None,
847                                    rendering_intent: 0,
848                                    transfer: Default::default(),
849                                    halftone: Default::default(),
850                                    bg_ucr: Default::default(),
851                                    alpha,
852                                    blend_mode: 3, // Multiply for highlight
853                                    alpha_is_shape: false,
854                                },
855                            });
856                        } else {
857                            // StrikeOut/Underline/Squiggly: draw a line
858                            let (lx1, ly1, lx2, ly2) = if subtype == b"StrikeOut" {
859                                // Middle of the quad
860                                (
861                                    (x1 + x3) / 2.0,
862                                    (y1 + y3) / 2.0,
863                                    (x2 + x4) / 2.0,
864                                    (y2 + y4) / 2.0,
865                                )
866                            } else {
867                                // Bottom of the quad
868                                (x3, y3, x4, y4)
869                            };
870                            let path = PsPath {
871                                segments: vec![
872                                    PathSegment::MoveTo(lx1, ly1),
873                                    PathSegment::LineTo(lx2, ly2),
874                                ],
875                            };
876                            self.display_list.push(DisplayElement::Stroke {
877                                path,
878                                params: StrokeParams {
879                                    color: color.clone(),
880                                    line_width: border_width,
881                                    line_cap: LineCap::Butt,
882                                    line_join: LineJoin::Miter,
883                                    miter_limit: 10.0,
884                                    dash_pattern: DashPattern::default(),
885                                    ctm,
886                                    stroke_adjust: false,
887                                    is_text_glyph: false,
888                                    overprint: false,
889                                    overprint_mode: 0,
890                                    opm_paired: false,
891                                    painted_channels: 0,
892                                    is_device_cmyk: false,
893                                    spot_color: None,
894                                    icc_color: None,
895                                    rendering_intent: 0,
896                                    transfer: Default::default(),
897                                    halftone: Default::default(),
898                                    bg_ucr: Default::default(),
899                                    alpha,
900                                    blend_mode: 0,
901                                    alpha_is_shape: false,
902                                },
903                            });
904                        }
905                    }
906                }
907            }
908            b"Square" => {
909                // Skip entirely if no border and no interior color
910                let has_ic = dict.get_array(b"IC").is_some();
911                if border_width < 0.001 && !has_ic {
912                    return Ok(());
913                }
914                let path = PsPath {
915                    segments: vec![
916                        PathSegment::MoveTo(rect[0], rect[1]),
917                        PathSegment::LineTo(rect[2], rect[1]),
918                        PathSegment::LineTo(rect[2], rect[3]),
919                        PathSegment::LineTo(rect[0], rect[3]),
920                        PathSegment::ClosePath,
921                    ],
922                };
923                // Fill with /IC (interior color) if present
924                if let Some(ic) = dict.get_array(b"IC") {
925                    let vals: Vec<f64> = ic.iter().filter_map(|o| o.as_f64()).collect();
926                    let ic_color = match vals.len() {
927                        1 => DeviceColor::from_gray(vals[0]),
928                        3 => DeviceColor::from_rgb(vals[0], vals[1], vals[2]),
929                        4 => DeviceColor::from_cmyk(vals[0], vals[1], vals[2], vals[3]),
930                        _ => DeviceColor::from_gray(1.0),
931                    };
932                    self.display_list.push(DisplayElement::Fill {
933                        path: path.clone(),
934                        params: FillParams {
935                            color: ic_color,
936                            fill_rule: FillRule::NonZeroWinding,
937                            ctm,
938                            is_text_glyph: false,
939                            overprint: false,
940                            overprint_mode: 0,
941                            opm_paired: false,
942                            painted_channels: 0,
943                            is_device_cmyk: false,
944                            spot_color: None,
945                            icc_color: None,
946                            rendering_intent: 0,
947                            transfer: Default::default(),
948                            halftone: Default::default(),
949                            bg_ucr: Default::default(),
950                            alpha,
951                            blend_mode: 0,
952                            alpha_is_shape: false,
953                        },
954                    });
955                }
956                if border_width < 0.001 {
957                    return Ok(());
958                }
959                self.display_list.push(DisplayElement::Stroke {
960                    path,
961                    params: StrokeParams {
962                        color,
963                        line_width: border_width,
964                        line_cap: LineCap::Butt,
965                        line_join: LineJoin::Miter,
966                        miter_limit: 10.0,
967                        dash_pattern: dash,
968                        ctm,
969                        stroke_adjust: false,
970                        is_text_glyph: false,
971                        overprint: false,
972                        overprint_mode: 0,
973                        opm_paired: false,
974                        painted_channels: 0,
975                        is_device_cmyk: false,
976                        spot_color: None,
977                        icc_color: None,
978                        rendering_intent: 0,
979                        transfer: Default::default(),
980                        halftone: Default::default(),
981                        bg_ucr: Default::default(),
982                        alpha,
983                        blend_mode: 0,
984                        alpha_is_shape: false,
985                    },
986                });
987            }
988            b"Circle" => {
989                let has_ic = dict.get_array(b"IC").is_some();
990                if border_width < 0.001 && !has_ic {
991                    return Ok(());
992                }
993                // Approximate circle/ellipse with Bezier curves
994                let cx = (rect[0] + rect[2]) / 2.0;
995                let cy = (rect[1] + rect[3]) / 2.0;
996                let rx = (rect[2] - rect[0]) / 2.0;
997                let ry = (rect[3] - rect[1]) / 2.0;
998                let k = 0.5522847498; // magic number for circular Bezier approximation
999                let path = PsPath {
1000                    segments: vec![
1001                        PathSegment::MoveTo(cx + rx, cy),
1002                        PathSegment::CurveTo {
1003                            x1: cx + rx,
1004                            y1: cy + ry * k,
1005                            x2: cx + rx * k,
1006                            y2: cy + ry,
1007                            x3: cx,
1008                            y3: cy + ry,
1009                        },
1010                        PathSegment::CurveTo {
1011                            x1: cx - rx * k,
1012                            y1: cy + ry,
1013                            x2: cx - rx,
1014                            y2: cy + ry * k,
1015                            x3: cx - rx,
1016                            y3: cy,
1017                        },
1018                        PathSegment::CurveTo {
1019                            x1: cx - rx,
1020                            y1: cy - ry * k,
1021                            x2: cx - rx * k,
1022                            y2: cy - ry,
1023                            x3: cx,
1024                            y3: cy - ry,
1025                        },
1026                        PathSegment::CurveTo {
1027                            x1: cx + rx * k,
1028                            y1: cy - ry,
1029                            x2: cx + rx,
1030                            y2: cy - ry * k,
1031                            x3: cx + rx,
1032                            y3: cy,
1033                        },
1034                        PathSegment::ClosePath,
1035                    ],
1036                };
1037                if let Some(ic) = dict.get_array(b"IC") {
1038                    let vals: Vec<f64> = ic.iter().filter_map(|o| o.as_f64()).collect();
1039                    let ic_color = match vals.len() {
1040                        1 => DeviceColor::from_gray(vals[0]),
1041                        3 => DeviceColor::from_rgb(vals[0], vals[1], vals[2]),
1042                        4 => DeviceColor::from_cmyk(vals[0], vals[1], vals[2], vals[3]),
1043                        _ => DeviceColor::from_gray(1.0),
1044                    };
1045                    self.display_list.push(DisplayElement::Fill {
1046                        path: path.clone(),
1047                        params: FillParams {
1048                            color: ic_color,
1049                            fill_rule: FillRule::NonZeroWinding,
1050                            ctm,
1051                            is_text_glyph: false,
1052                            overprint: false,
1053                            overprint_mode: 0,
1054                            opm_paired: false,
1055                            painted_channels: 0,
1056                            is_device_cmyk: false,
1057                            spot_color: None,
1058                            icc_color: None,
1059                            rendering_intent: 0,
1060                            transfer: Default::default(),
1061                            halftone: Default::default(),
1062                            bg_ucr: Default::default(),
1063                            alpha,
1064                            blend_mode: 0,
1065                            alpha_is_shape: false,
1066                        },
1067                    });
1068                }
1069                if border_width < 0.001 {
1070                    return Ok(());
1071                }
1072                self.display_list.push(DisplayElement::Stroke {
1073                    path,
1074                    params: StrokeParams {
1075                        color,
1076                        line_width: border_width,
1077                        line_cap: LineCap::Butt,
1078                        line_join: LineJoin::Miter,
1079                        miter_limit: 10.0,
1080                        dash_pattern: dash,
1081                        ctm,
1082                        stroke_adjust: false,
1083                        is_text_glyph: false,
1084                        overprint: false,
1085                        overprint_mode: 0,
1086                        opm_paired: false,
1087                        painted_channels: 0,
1088                        is_device_cmyk: false,
1089                        spot_color: None,
1090                        icc_color: None,
1091                        rendering_intent: 0,
1092                        transfer: Default::default(),
1093                        halftone: Default::default(),
1094                        bg_ucr: Default::default(),
1095                        alpha,
1096                        blend_mode: 0,
1097                        alpha_is_shape: false,
1098                    },
1099                });
1100            }
1101            _ => {
1102                // Unsupported annotation type without AP — skip silently
1103            }
1104        }
1105
1106        Ok(())
1107    }
1108
1109    /// Interpret content stream bytes (can be called recursively for Form XObjects).
1110    fn interpret_stream(&mut self, data: &[u8]) -> Result<(), PdfError> {
1111        // Isolate the operand stack from any caller.  This matters for the
1112        // recursive entries (Form XObjects, tiling patterns, Type 3 glyphs,
1113        // soft-mask groups), which are reached via dispatch_operator while
1114        // the parent's operand stack still has the operator's own operand
1115        // (e.g. the `/F1` for `Do`) on it.  Without this, the parent operand
1116        // would be seen by the first operator in the nested stream and
1117        // (because of the operand-count guard in dispatch_operator) silently
1118        // drop it.  See circ_compare.pdf reproduction for the original bug.
1119        let saved_operand_stack = std::mem::take(&mut self.operand_stack);
1120        let result = self.interpret_stream_inner(data);
1121        self.operand_stack = saved_operand_stack;
1122        result
1123    }
1124
1125    fn interpret_stream_inner(&mut self, data: &[u8]) -> Result<(), PdfError> {
1126        let mut lexer = Lexer::new(data);
1127        // Tracks whether the previous token was a number whose terminating
1128        // byte was *not* whitespace.  Used to detect lenient lexing of
1129        // sequences like `5f` (= `5 f`), where the painter is glued to a
1130        // preceding number with no separator.  pdf.js, GhostScript, hayro,
1131        // Ocular, and Firefox all interpret these as `<number> <operator>`.
1132        let mut prev_token_was_glued_number = false;
1133        loop {
1134            // Capture position before next_token so we can detect whether the
1135            // upcoming token is glued to the previous one (no whitespace
1136            // separator).  If the previous token was a number that ended on a
1137            // non-whitespace byte, this token is glued to it.
1138            let pos_before = lexer.pos();
1139            let glued_to_prev_number = prev_token_was_glued_number
1140                && pos_before < data.len()
1141                && !is_whitespace_byte(data[pos_before]);
1142            let tok = match lexer.next_token() {
1143                Ok(t) => t,
1144                Err(_) => {
1145                    prev_token_was_glued_number = false;
1146                    continue;
1147                }
1148            };
1149            // Default: clear "glued number" flag.  Set it again below for
1150            // numeric tokens that ended on a non-whitespace byte.
1151            prev_token_was_glued_number = false;
1152            match tok {
1153                Token::Eof => break,
1154                Token::Int(n) => {
1155                    self.operand_stack.push(Operand::Int(n));
1156                    let p = lexer.pos();
1157                    prev_token_was_glued_number = p < data.len() && !is_whitespace_byte(data[p]);
1158                }
1159                Token::Real(f) => {
1160                    self.operand_stack.push(Operand::Real(f));
1161                    let p = lexer.pos();
1162                    prev_token_was_glued_number = p < data.len() && !is_whitespace_byte(data[p]);
1163                }
1164                Token::Name(n) => self.operand_stack.push(Operand::Name(n)),
1165                Token::LitString(s) | Token::HexString(s) => {
1166                    self.operand_stack.push(Operand::Str(s));
1167                }
1168                Token::Bool(b) => self.operand_stack.push(Operand::Bool(b)),
1169                Token::ArrayBegin => {
1170                    let arr = Self::parse_inline_array(&mut lexer)?;
1171                    self.operand_stack.push(Operand::Array(arr));
1172                }
1173                Token::DictBegin => {
1174                    let dict = crate::lexer::parse_dict_body(&mut lexer)?;
1175                    self.operand_stack.push(Operand::Dict(dict));
1176                }
1177                Token::Keyword(kw) => {
1178                    // Check for operator suffixes:
1179                    // * suffix: f*, B*, b*, W*, T*
1180                    // digit suffix: d0, d1 (Type 3 glyph operators)
1181                    let op = if matches!(kw.as_slice(), b"f" | b"B" | b"b" | b"W" | b"T") {
1182                        let p = lexer.pos();
1183                        if p < data.len() && data[p] == b'*' {
1184                            lexer.set_pos(p + 1);
1185                            let mut combined = kw;
1186                            combined.push(b'*');
1187                            combined
1188                        } else {
1189                            kw
1190                        }
1191                    } else if kw == b"d" {
1192                        let p = lexer.pos();
1193                        if p < data.len() && (data[p] == b'0' || data[p] == b'1') {
1194                            lexer.set_pos(p + 1);
1195                            let mut combined = kw;
1196                            combined.push(data[p]);
1197                            combined
1198                        } else {
1199                            kw
1200                        }
1201                    } else {
1202                        kw
1203                    };
1204
1205                    if op == b"BI" {
1206                        self.handle_inline_image(&mut lexer)?;
1207                    } else if let Err(_e) = self.dispatch_operator(&op, glued_to_prev_number) {
1208                    }
1209                    self.operand_stack.clear();
1210                }
1211                Token::DictEnd | Token::ArrayEnd => {
1212                    // Stray delimiters — ignore
1213                }
1214            }
1215        }
1216        Ok(())
1217    }
1218
1219    /// Parse an inline array from the content stream.
1220    ///
1221    /// Nesting is capped at [`MAX_OBJECT_DEPTH`]: this is a recursive-descent
1222    /// parser, so an operand like `[[[[…` in a crafted content stream would
1223    /// otherwise exhaust the native stack and abort the process.
1224    fn parse_inline_array(lexer: &mut Lexer) -> Result<Vec<PdfObj>, PdfError> {
1225        Self::parse_inline_array_at_depth(lexer, 1)
1226    }
1227
1228    /// [`Self::parse_inline_array`], entered at an explicit nesting depth.
1229    ///
1230    /// `depth` counts this array itself, matching the convention used by
1231    /// [`crate::lexer::parse_dict_body_at_depth`].
1232    fn parse_inline_array_at_depth(lexer: &mut Lexer, depth: u32) -> Result<Vec<PdfObj>, PdfError> {
1233        if depth > MAX_OBJECT_DEPTH {
1234            return Err(PdfError::NestingTooDeep {
1235                context: "content-stream array",
1236                limit: MAX_OBJECT_DEPTH,
1237            });
1238        }
1239        let mut elems = Vec::new();
1240        loop {
1241            let tok = lexer.next_token()?;
1242            match tok {
1243                Token::ArrayEnd | Token::Eof => break,
1244                Token::Int(n) => elems.push(PdfObj::Int(n)),
1245                Token::Real(f) => elems.push(PdfObj::Real(f)),
1246                Token::Name(n) => elems.push(PdfObj::Name(n)),
1247                Token::LitString(s) | Token::HexString(s) => elems.push(PdfObj::Str(s)),
1248                Token::Bool(b) => elems.push(PdfObj::Bool(b)),
1249                Token::ArrayBegin => {
1250                    // A nested array past the cap yields an error rather than
1251                    // another frame; skip it and keep scanning for `]` so the
1252                    // surrounding operand stream stays in sync.
1253                    match Self::parse_inline_array_at_depth(lexer, depth + 1) {
1254                        Ok(sub) => elems.push(PdfObj::Array(sub)),
1255                        Err(_) => continue,
1256                    }
1257                }
1258                Token::DictBegin => {
1259                    let d = crate::lexer::parse_dict_body_at_depth(lexer, depth + 1)
1260                        .unwrap_or_default();
1261                    elems.push(PdfObj::Dict(d));
1262                }
1263                Token::Keyword(ref kw) if kw == b"null" => {
1264                    elems.push(PdfObj::Null);
1265                }
1266                _ => {}
1267            }
1268        }
1269        Ok(elems)
1270    }
1271
1272    /// Dispatch a PDF content stream operator.
1273    ///
1274    /// `glued_to_prev_number` is true when the operator token in the source
1275    /// stream was immediately preceded by a number with no whitespace
1276    /// separator (e.g. `5f`).  Lenient parsers (pdf.js, GhostScript, hayro,
1277    /// Ocular, Firefox) all interpret such sequences as `<number> <operator>`,
1278    /// and this flag lets us bypass the operand-count guard so the painter
1279    /// still runs.  Without that escape hatch, the guard would silently drop
1280    /// the painter, leaving the path unpainted (issue994.pdf).
1281    fn dispatch_operator(&mut self, op: &[u8], glued_to_prev_number: bool) -> Result<(), PdfError> {
1282        // Path construction and painting operators have fixed operand counts.
1283        // Excess operands indicate garbled content stream data (e.g. from
1284        // corrupt FlateDecode) — skip the operator to avoid rendering with
1285        // wrong coordinates.  The operand stack is cleared after every
1286        // dispatch, so any values present were pushed since the last operator.
1287        let expected_args: i32 = match op {
1288            b"m" | b"l" => 2,
1289            b"v" | b"y" | b"re" => 4,
1290            b"c" => 6,
1291            b"h" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n" => 0,
1292            _ => -1, // no check
1293        };
1294        if expected_args >= 0
1295            && self.operand_stack.len() > expected_args as usize
1296            && !glued_to_prev_number
1297        {
1298            return Ok(());
1299        }
1300
1301        // Skip text operators inside a culled BT/ET block (offscreen in large forms)
1302        if self.bt_culled {
1303            if op == b"ET" {
1304                self.bt_culled = false;
1305                self.in_text = false;
1306            }
1307            self.operand_stack.clear();
1308            return Ok(());
1309        }
1310
1311        match op {
1312            // Graphics state
1313            b"q" => self.op_q(),
1314            b"Q" => self.op_big_q(),
1315            b"cm" => self.op_cm(),
1316            b"w" => self.op_w(),
1317            b"J" => self.op_big_j(),
1318            b"j" => self.op_j(),
1319            b"M" => self.op_big_m(),
1320            b"d" => self.op_d(),
1321            b"ri" => self.op_ri(),
1322            b"i" => self.op_i(),
1323            b"gs" => self.op_gs(),
1324
1325            // Path construction
1326            b"m" => self.op_m(),
1327            b"l" => self.op_l(),
1328            b"c" => self.op_c(),
1329            b"v" => self.op_v(),
1330            b"y" => self.op_y(),
1331            b"h" => self.op_h(),
1332            b"re" => self.op_re(),
1333
1334            // Path painting
1335            b"S" => self.op_big_s(),
1336            b"s" => self.op_small_s(),
1337            b"f" | b"F" => self.op_f(),
1338            b"f*" => self.op_f_star(),
1339            b"B" => self.op_big_b(),
1340            b"B*" => self.op_big_b_star(),
1341            b"b" => self.op_small_b(),
1342            b"b*" => self.op_small_b_star(),
1343            b"n" => self.op_n(),
1344
1345            // Clipping
1346            b"W" => self.op_big_w(),
1347            b"W*" => self.op_big_w_star(),
1348
1349            // Color - device
1350            b"G" if !self.d1_color_suppressed => self.op_big_g(),
1351            b"g" if !self.d1_color_suppressed => self.op_small_g(),
1352            b"RG" if !self.d1_color_suppressed => self.op_big_rg(),
1353            b"rg" if !self.d1_color_suppressed => self.op_small_rg(),
1354            b"K" if !self.d1_color_suppressed => self.op_big_k(),
1355            b"k" if !self.d1_color_suppressed => self.op_small_k(),
1356            b"G" | b"g" | b"RG" | b"rg" | b"K" | b"k" => Ok(()),
1357
1358            // Color - general
1359            b"CS" if !self.d1_color_suppressed => self.op_big_cs(),
1360            b"cs" if !self.d1_color_suppressed => self.op_small_cs(),
1361            b"SC" | b"SCN" if !self.d1_color_suppressed => self.op_sc_stroke(),
1362            b"sc" | b"scn" if !self.d1_color_suppressed => self.op_sc_fill(),
1363            b"CS" | b"cs" | b"SC" | b"SCN" | b"sc" | b"scn" => Ok(()),
1364
1365            // Text operators
1366            b"BT" => {
1367                self.in_text = true;
1368                self.gstate.text_matrix = Matrix::identity();
1369                self.gstate.text_line_matrix = Matrix::identity();
1370                Ok(())
1371            }
1372            b"ET" => {
1373                self.in_text = false;
1374                // Apply accumulated text clip path (from rendering modes 4-7)
1375                if let Some(clip_path) = self.text_clip_path.take()
1376                    && !clip_path.is_empty()
1377                {
1378                    self.display_list.push(DisplayElement::Clip {
1379                        path: clip_path.clone(),
1380                        params: ClipParams {
1381                            fill_rule: FillRule::NonZeroWinding,
1382                            ctm: Matrix::identity(),
1383                            stroke_params: None,
1384                        },
1385                    });
1386                    // Track in graphics state so Q/grestore can undo it
1387                    self.gstate
1388                        .clip_stack
1389                        .push((clip_path.clone(), FillRule::NonZeroWinding));
1390                    self.gstate.clip_path = Some(clip_path);
1391                    self.gstate.clip_path_version += 1;
1392                }
1393                Ok(())
1394            }
1395            b"Tf" => self.op_tf(),
1396            b"Tc" => {
1397                self.gstate.char_spacing = self.pop_number()?;
1398                Ok(())
1399            }
1400            b"Tw" => {
1401                self.gstate.word_spacing = self.pop_number()?;
1402                Ok(())
1403            }
1404            b"TL" => {
1405                self.gstate.text_leading = self.pop_number()?;
1406                Ok(())
1407            }
1408            b"Tr" => {
1409                self.gstate.text_rendering_mode = self.pop_number()? as i32;
1410                Ok(())
1411            }
1412            b"Ts" => {
1413                self.gstate.text_rise = self.pop_number()?;
1414                Ok(())
1415            }
1416            b"Tz" => {
1417                self.gstate.horizontal_scaling = self.pop_number()? / 100.0;
1418                Ok(())
1419            }
1420            b"Td" => self.op_td(),
1421            b"TD" => self.op_big_td(),
1422            b"Tm" => self.op_tm(),
1423            b"T*" => self.op_t_star(),
1424            b"Tj" => self.op_tj(),
1425            b"TJ" => self.op_big_tj(),
1426            b"'" => self.op_quote(),
1427            b"\"" => self.op_dblquote(),
1428
1429            // XObject
1430            b"Do" => self.op_do(),
1431
1432            // Shading
1433            b"sh" => self.op_sh(),
1434
1435            // Marked content with optional content group (OCG) support.
1436            // BDC/BMC open a section that must be closed by EMC; MP/DP are
1437            // single-shot marked points with no closing operator.
1438            b"BMC" => {
1439                self.operand_stack.pop();
1440                self.mc_stack.push(MarkedContentFrame::Other);
1441                Ok(())
1442            }
1443            b"MP" => {
1444                self.operand_stack.pop();
1445                Ok(())
1446            }
1447            b"DP" => {
1448                self.operand_stack.pop();
1449                self.operand_stack.pop();
1450                Ok(())
1451            }
1452            b"BDC" => self.op_bdc(),
1453            b"EMC" => {
1454                if let Some(MarkedContentFrame::Ocg {
1455                    parent_list,
1456                    visibility,
1457                }) = self.mc_stack.pop()
1458                {
1459                    let ocg_list = std::mem::replace(&mut self.display_list, parent_list);
1460                    self.display_list.push(DisplayElement::OcgGroup {
1461                        elements: ocg_list,
1462                        visibility,
1463                    });
1464                }
1465                Ok(())
1466            }
1467
1468            // Type 3 glyph operators (width/cache — we use Widths array instead)
1469            b"d0" => Ok(()),
1470            b"d1" => {
1471                // d1: Type 3 glyph with colored content disabled. Per PDF spec
1472                // 9.6.5, the glyph description shall be treated as a single
1473                // mask whose source colour is the current colour. Color
1474                // operators inside the glyph are ignored, AND any path painting
1475                // operation (fill or stroke) inside the glyph must use the
1476                // single text color — which for the default text rendering
1477                // mode 0 is the fill color. Force the stroke color (and any
1478                // patterns) to match the fill color so a glyph procedure that
1479                // calls `S` or `b` paints with the fill color, not whatever
1480                // stroke color happened to be set on the page.
1481                self.d1_color_suppressed = true;
1482                self.gstate.stroke_color = self.gstate.fill_color.clone();
1483                self.gstate.stroke_color_space = self.gstate.fill_color_space.clone();
1484                self.gstate.stroke_pattern = None;
1485                self.gstate.stroke_shading_pattern = None;
1486                self.gstate.stroke_painted_channels = self.gstate.fill_painted_channels;
1487                self.gstate.stroke_is_device_cmyk = self.gstate.fill_is_device_cmyk;
1488                self.gstate.stroke_is_none = self.gstate.fill_is_none;
1489                Ok(())
1490            }
1491
1492            // Compatibility (no-op)
1493            b"BX" | b"EX" => Ok(()),
1494
1495            _ => {
1496                // Unknown operator — ignore
1497                Ok(())
1498            }
1499        }
1500    }
1501
1502    // === Helper methods ===
1503
1504    /// Pop one number from the operand stack.
1505    fn pop_number(&self) -> Result<f64, PdfError> {
1506        self.operand_stack
1507            .last()
1508            .and_then(|o| o.as_f64())
1509            .ok_or(PdfError::Other("expected number on operand stack".into()))
1510    }
1511
1512    /// Get N numbers from the end of the operand stack.
1513    fn get_numbers(&self, n: usize) -> Result<Vec<f64>, PdfError> {
1514        let len = self.operand_stack.len();
1515        if len < n {
1516            return Err(PdfError::Other(format!("need {n} operands, have {len}")));
1517        }
1518        let mut nums = Vec::with_capacity(n);
1519        for i in (len - n)..len {
1520            nums.push(
1521                self.operand_stack[i]
1522                    .as_f64()
1523                    .ok_or(PdfError::Other("expected number".into()))?,
1524            );
1525        }
1526        Ok(nums)
1527    }
1528
1529    /// Transform a point through the current CTM to device space.
1530    fn transform(&self, x: f64, y: f64) -> (f64, f64) {
1531        self.gstate.ctm.transform_point(x, y)
1532    }
1533
1534    /// Take the current path and reset it.
1535    fn take_path(&mut self) -> PsPath {
1536        let path = std::mem::take(&mut self.current_path);
1537        self.current_point = None;
1538        self.subpath_start = None;
1539        path
1540    }
1541
1542    /// Apply pending clip if set, then clear it.
1543    fn apply_pending_clip(&mut self) {
1544        if let Some((path, fill_rule)) = self.gstate.pending_clip.take() {
1545            // A clip path with only MoveTo segments (no lines, curves, or close)
1546            // has zero area — it clips everything out. Replace with an empty rect
1547            // so the renderer produces a zero-area clip instead of treating the
1548            // degenerate path as no-op.
1549            let has_drawing_segments = path.segments.iter().any(|s| {
1550                matches!(
1551                    s,
1552                    PathSegment::LineTo(..) | PathSegment::CurveTo { .. } | PathSegment::ClosePath
1553                )
1554            });
1555            // A path with MoveTo but no lines/curves has zero area — clip everything.
1556            // An empty path (no segments at all) is a no-op — skip the clip entirely.
1557            let has_moveto = path
1558                .segments
1559                .iter()
1560                .any(|s| matches!(s, PathSegment::MoveTo(..)));
1561            if !has_drawing_segments && has_moveto {
1562                // Degenerate path (only MoveTo): create a zero-area clip
1563                let mut empty = PsPath::new();
1564                empty.segments.push(PathSegment::MoveTo(0.0, 0.0));
1565                empty.segments.push(PathSegment::LineTo(0.0, 0.0));
1566                empty.segments.push(PathSegment::ClosePath);
1567                self.display_list.push(DisplayElement::Clip {
1568                    path: empty.clone(),
1569                    params: ClipParams {
1570                        fill_rule,
1571                        ctm: Matrix::identity(),
1572                        stroke_params: None,
1573                    },
1574                });
1575                self.gstate.clip_stack.push((empty.clone(), fill_rule));
1576                self.gstate.clip_path = Some(empty);
1577                self.gstate.clip_path_version += 1;
1578                return;
1579            }
1580            if !has_drawing_segments {
1581                // Empty path: no-op, don't change clip
1582                return;
1583            }
1584            let clip_path = path;
1585            self.display_list.push(DisplayElement::Clip {
1586                path: clip_path.clone(),
1587                params: ClipParams {
1588                    fill_rule,
1589                    ctm: Matrix::identity(),
1590                    stroke_params: None,
1591                },
1592            });
1593            // Track the clip for restoring on Q
1594            self.gstate.clip_stack.push((clip_path.clone(), fill_rule));
1595            self.gstate.clip_path = Some(clip_path);
1596            self.gstate.clip_path_version += 1;
1597        }
1598    }
1599
1600    // === Graphics state operators ===
1601
1602    fn op_q(&mut self) -> Result<(), PdfError> {
1603        self.gstate_stack.push(self.gstate.clone());
1604        Ok(())
1605    }
1606
1607    fn op_big_q(&mut self) -> Result<(), PdfError> {
1608        if let Some(saved) = self.gstate_stack.pop() {
1609            // Flush soft mask scope if the SMask changed during this q/Q block.
1610            // Compare the smask_gen counter: if it changed, a new SMask was set
1611            // inside this block and the scope should be flushed. This correctly
1612            // handles nested masks inside resolve_soft_mask where both current
1613            // and saved gstates have a soft_mask but they're different.
1614            if self.soft_mask_scope.is_some() && self.gstate.smask_gen != saved.smask_gen {
1615                self.flush_soft_mask();
1616                self.nested_mask_flush_count += 1;
1617            }
1618
1619            let old_clip_version = self.gstate.clip_path_version;
1620            let old_font_name = std::mem::take(&mut self.gstate.text_font_name);
1621            self.gstate = saved;
1622            // If clip changed during the q/Q block, restore it by
1623            // replaying the full clip stack (not just the last clip).
1624            if self.gstate.clip_path_version != old_clip_version {
1625                self.restore_clip_from_stack();
1626            }
1627            // Re-resolve current_font if the restored font name differs
1628            if self.gstate.text_font_name != old_font_name && !self.gstate.text_font_name.is_empty()
1629            {
1630                let name = self.gstate.text_font_name.clone();
1631                self.resolve_current_font(&name);
1632            }
1633        }
1634        Ok(())
1635    }
1636
1637    /// Restore the clip state by pushing InitClip + replaying all clips from clip_stack.
1638    fn restore_clip_from_stack(&mut self) {
1639        self.display_list.push(DisplayElement::InitClip);
1640        for (clip, fill_rule) in &self.gstate.clip_stack {
1641            self.display_list.push(DisplayElement::Clip {
1642                path: clip.clone(),
1643                params: ClipParams {
1644                    fill_rule: *fill_rule,
1645                    ctm: Matrix::identity(),
1646                    stroke_params: None,
1647                },
1648            });
1649        }
1650    }
1651
1652    fn op_cm(&mut self) -> Result<(), PdfError> {
1653        let n = self.get_numbers(6)?;
1654        let m = Matrix::new(n[0], n[1], n[2], n[3], n[4], n[5]);
1655        // PDF cm: CTM = CTM × M (pre-multiply, same as PS concat)
1656        self.gstate.ctm = self.gstate.ctm.concat(&m);
1657        Ok(())
1658    }
1659
1660    fn op_w(&mut self) -> Result<(), PdfError> {
1661        self.gstate.line_width = self.pop_number()?;
1662        Ok(())
1663    }
1664
1665    fn op_big_j(&mut self) -> Result<(), PdfError> {
1666        let cap = self.pop_number()? as i32;
1667        if let Some(lc) = LineCap::from_i32(cap) {
1668            self.gstate.line_cap = lc;
1669        }
1670        Ok(())
1671    }
1672
1673    fn op_j(&mut self) -> Result<(), PdfError> {
1674        let join = self.pop_number()? as i32;
1675        if let Some(lj) = LineJoin::from_i32(join) {
1676            self.gstate.line_join = lj;
1677        }
1678        Ok(())
1679    }
1680
1681    fn op_big_m(&mut self) -> Result<(), PdfError> {
1682        self.gstate.miter_limit = self.pop_number()?;
1683        Ok(())
1684    }
1685
1686    fn op_d(&mut self) -> Result<(), PdfError> {
1687        // Operands: array offset
1688        let len = self.operand_stack.len();
1689        if len < 2 {
1690            return Ok(());
1691        }
1692        let offset = self.operand_stack[len - 1].as_f64().unwrap_or(0.0);
1693        let array = match &self.operand_stack[len - 2] {
1694            Operand::Array(arr) => arr.iter().filter_map(|o| o.as_f64()).collect::<Vec<_>>(),
1695            _ => Vec::new(),
1696        };
1697        self.gstate.dash_pattern = DashPattern { array, offset };
1698        Ok(())
1699    }
1700
1701    fn op_ri(&mut self) -> Result<(), PdfError> {
1702        // Pop the intent name and translate to the gstate byte code used
1703        // by the ICC chain dispatch (matches `IccCache::intent_from_pdf_byte`).
1704        let Some(top) = self.operand_stack.pop() else {
1705            return Ok(());
1706        };
1707        let Some(name) = top.as_name() else {
1708            return Ok(());
1709        };
1710        self.gstate.rendering_intent = match name {
1711            b"Perceptual" => 0,
1712            b"RelativeColorimetric" => 1,
1713            b"Saturation" => 2,
1714            b"AbsoluteColorimetric" => 3,
1715            _ => 0,
1716        };
1717        Ok(())
1718    }
1719
1720    fn op_i(&mut self) -> Result<(), PdfError> {
1721        self.gstate.flatness = self.pop_number()?;
1722        Ok(())
1723    }
1724
1725    fn op_gs(&mut self) -> Result<(), PdfError> {
1726        let name = self
1727            .operand_stack
1728            .last()
1729            .and_then(|o| o.as_name())
1730            .ok_or(PdfError::Other("gs: expected name".into()))?
1731            .to_vec();
1732        self.apply_ext_gstate(&name)
1733    }
1734
1735    // === Path construction operators ===
1736
1737    fn op_m(&mut self) -> Result<(), PdfError> {
1738        let n = self.get_numbers(2)?;
1739        let (dx, dy) = self.transform(n[0], n[1]);
1740        self.current_path.segments.push(PathSegment::MoveTo(dx, dy));
1741        self.current_point = Some((dx, dy));
1742        self.subpath_start = Some((dx, dy));
1743        Ok(())
1744    }
1745
1746    fn op_l(&mut self) -> Result<(), PdfError> {
1747        let n = self.get_numbers(2)?;
1748        let (dx, dy) = self.transform(n[0], n[1]);
1749        self.current_path.segments.push(PathSegment::LineTo(dx, dy));
1750        self.current_point = Some((dx, dy));
1751        Ok(())
1752    }
1753
1754    fn op_c(&mut self) -> Result<(), PdfError> {
1755        let n = self.get_numbers(6)?;
1756        let (x1, y1) = self.transform(n[0], n[1]);
1757        let (x2, y2) = self.transform(n[2], n[3]);
1758        let (x3, y3) = self.transform(n[4], n[5]);
1759        self.current_path.segments.push(PathSegment::CurveTo {
1760            x1,
1761            y1,
1762            x2,
1763            y2,
1764            x3,
1765            y3,
1766        });
1767        self.current_point = Some((x3, y3));
1768        Ok(())
1769    }
1770
1771    fn op_v(&mut self) -> Result<(), PdfError> {
1772        let n = self.get_numbers(4)?;
1773        let (x1, y1) = self.current_point.unwrap_or((0.0, 0.0));
1774        let (x2, y2) = self.transform(n[0], n[1]);
1775        let (x3, y3) = self.transform(n[2], n[3]);
1776        self.current_path.segments.push(PathSegment::CurveTo {
1777            x1,
1778            y1,
1779            x2,
1780            y2,
1781            x3,
1782            y3,
1783        });
1784        self.current_point = Some((x3, y3));
1785        Ok(())
1786    }
1787
1788    fn op_y(&mut self) -> Result<(), PdfError> {
1789        let n = self.get_numbers(4)?;
1790        let (x1, y1) = self.transform(n[0], n[1]);
1791        let (x3, y3) = self.transform(n[2], n[3]);
1792        self.current_path.segments.push(PathSegment::CurveTo {
1793            x1,
1794            y1,
1795            x2: x3,
1796            y2: y3,
1797            x3,
1798            y3,
1799        });
1800        self.current_point = Some((x3, y3));
1801        Ok(())
1802    }
1803
1804    fn op_h(&mut self) -> Result<(), PdfError> {
1805        self.current_path.segments.push(PathSegment::ClosePath);
1806        if let Some(start) = self.subpath_start {
1807            self.current_point = Some(start);
1808        }
1809        Ok(())
1810    }
1811
1812    fn op_re(&mut self) -> Result<(), PdfError> {
1813        let n = self.get_numbers(4)?;
1814        let (x, y, w, h) = (n[0], n[1], n[2], n[3]);
1815        // re builds: m x y, l x+w y, l x+w y+h, l x y+h, h
1816        let p0 = self.transform(x, y);
1817        let p1 = self.transform(x + w, y);
1818        let p2 = self.transform(x + w, y + h);
1819        let p3 = self.transform(x, y + h);
1820        self.current_path
1821            .segments
1822            .push(PathSegment::MoveTo(p0.0, p0.1));
1823        self.current_path
1824            .segments
1825            .push(PathSegment::LineTo(p1.0, p1.1));
1826        self.current_path
1827            .segments
1828            .push(PathSegment::LineTo(p2.0, p2.1));
1829        self.current_path
1830            .segments
1831            .push(PathSegment::LineTo(p3.0, p3.1));
1832        self.current_path.segments.push(PathSegment::ClosePath);
1833        self.current_point = Some(p0);
1834        self.subpath_start = Some(p0);
1835        Ok(())
1836    }
1837
1838    // === Path painting operators ===
1839
1840    fn op_big_s(&mut self) -> Result<(), PdfError> {
1841        // S: stroke
1842        let path = self.take_path();
1843        if !path.is_empty() {
1844            self.emit_stroke(path);
1845        }
1846        self.apply_pending_clip();
1847        Ok(())
1848    }
1849
1850    fn op_small_s(&mut self) -> Result<(), PdfError> {
1851        // s: close and stroke
1852        self.op_h()?;
1853        self.op_big_s()
1854    }
1855
1856    fn op_f(&mut self) -> Result<(), PdfError> {
1857        // f/F: fill (non-zero winding)
1858        let path = self.take_path();
1859        if !path.is_empty() {
1860            self.emit_fill(path, FillRule::NonZeroWinding);
1861        }
1862        self.apply_pending_clip();
1863        Ok(())
1864    }
1865
1866    fn op_f_star(&mut self) -> Result<(), PdfError> {
1867        // f*: fill (even-odd)
1868        let path = self.take_path();
1869        if !path.is_empty() {
1870            self.emit_fill(path, FillRule::EvenOdd);
1871        }
1872        self.apply_pending_clip();
1873        Ok(())
1874    }
1875
1876    fn op_big_b(&mut self) -> Result<(), PdfError> {
1877        // B: fill (non-zero) + stroke
1878        let path = self.take_path();
1879        if !path.is_empty() {
1880            self.emit_fill_stroke(path, FillRule::NonZeroWinding);
1881        }
1882        self.apply_pending_clip();
1883        Ok(())
1884    }
1885
1886    fn op_big_b_star(&mut self) -> Result<(), PdfError> {
1887        // B*: fill (even-odd) + stroke
1888        let path = self.take_path();
1889        if !path.is_empty() {
1890            self.emit_fill_stroke(path, FillRule::EvenOdd);
1891        }
1892        self.apply_pending_clip();
1893        Ok(())
1894    }
1895
1896    fn op_small_b(&mut self) -> Result<(), PdfError> {
1897        // b: close, fill (non-zero), stroke
1898        self.op_h()?;
1899        self.op_big_b()
1900    }
1901
1902    fn op_small_b_star(&mut self) -> Result<(), PdfError> {
1903        // b*: close, fill (even-odd), stroke
1904        self.op_h()?;
1905        self.op_big_b_star()
1906    }
1907
1908    /// Emit a fill — either a pattern fill or a regular solid fill.
1909    fn emit_fill(&mut self, path: PsPath, fill_rule: FillRule) {
1910        if let Some(shading_box) = self.gstate.fill_shading_pattern.clone() {
1911            // PatternType 2 (shading pattern): clip to fill path, then emit shading.
1912            // Wrap in a Group to scope the clip — otherwise each shading fill would
1913            // permanently narrow the clip region, hiding subsequent fills.
1914            let bbox = path_device_bbox(&path);
1915            let mut group_dl = DisplayList::new();
1916            group_dl.push(DisplayElement::Clip {
1917                path,
1918                params: ClipParams {
1919                    fill_rule,
1920                    ctm: Matrix::identity(),
1921                    stroke_params: None,
1922                },
1923            });
1924            for elem in shading_box.0.elements() {
1925                group_dl.push(elem.clone());
1926            }
1927            self.display_list.push(DisplayElement::Group {
1928                elements: group_dl,
1929                params: GroupParams {
1930                    bbox,
1931                    isolated: true,
1932                    knockout: false,
1933                    blend_mode: self.gstate.blend_mode,
1934                    alpha: self.gstate.fill_alpha,
1935                    color_space: stet_graphics::display_list::GroupColorSpace::Inherited,
1936                },
1937            });
1938        } else if let Some(pattern) = self.gstate.fill_pattern.clone() {
1939            self.display_list.push(DisplayElement::PatternFill {
1940                params: PatternFillParams {
1941                    path,
1942                    fill_rule,
1943                    tile: pattern.tile,
1944                    pattern_matrix: pattern.pattern_matrix,
1945                    bbox: pattern.bbox,
1946                    xstep: pattern.x_step,
1947                    ystep: pattern.y_step,
1948                    paint_type: pattern.paint_type,
1949                    underlying_color: if pattern.paint_type == 2 {
1950                        Some(self.gstate.fill_color.clone())
1951                    } else {
1952                        None
1953                    },
1954                    pattern_id: pattern.pattern_id,
1955                    device_space_tile: false,
1956                    flip_tile_y: false,
1957                    stroke_params: None,
1958                    overprint_mode: if self.gstate.overprint {
1959                        self.gstate.overprint_mode
1960                    } else {
1961                        0
1962                    },
1963                },
1964            });
1965        } else {
1966            self.display_list.push(DisplayElement::Fill {
1967                path,
1968                params: self.gstate.fill_params(fill_rule),
1969            });
1970        }
1971    }
1972
1973    /// Emit a stroke with proper CTM-aware line width.
1974    ///
1975    /// Paths are stored in device space, but strokes need to be applied in user
1976    /// space for correct anisotropic scaling (non-uniform CTMs make circles into
1977    /// ellipses, and the stroke width should follow that transformation).
1978    /// We inverse-transform the path back to user space and pass the CTM to the
1979    /// renderer so it can apply the stroke correctly.
1980    fn emit_stroke(&mut self, path: PsPath) {
1981        let ctm = self.gstate.ctm;
1982        // Inverse-transform path from device space back to user space
1983        let user_path = if let Some(inv) = ctm.invert() {
1984            path.transform(&inv)
1985        } else {
1986            path.clone()
1987        };
1988
1989        // Tiling pattern stroke: emit PatternFill with stroke_params so the
1990        // renderer expands the centerline path to a stroke outline for masking.
1991        if let Some(pattern) = self.gstate.stroke_pattern.clone() {
1992            let mut sp = self.gstate.stroke_params_with_ctm();
1993            sp.ctm = ctm;
1994            self.display_list.push(DisplayElement::PatternFill {
1995                params: PatternFillParams {
1996                    path: user_path,
1997                    fill_rule: FillRule::NonZeroWinding,
1998                    tile: pattern.tile,
1999                    pattern_matrix: pattern.pattern_matrix,
2000                    bbox: pattern.bbox,
2001                    xstep: pattern.x_step,
2002                    ystep: pattern.y_step,
2003                    paint_type: pattern.paint_type,
2004                    underlying_color: if pattern.paint_type == 2 {
2005                        Some(self.gstate.stroke_color.clone())
2006                    } else {
2007                        None
2008                    },
2009                    pattern_id: pattern.pattern_id,
2010                    device_space_tile: false,
2011                    flip_tile_y: false,
2012                    stroke_params: Some(sp),
2013                    overprint_mode: if self.gstate.overprint {
2014                        self.gstate.overprint_mode
2015                    } else {
2016                        0
2017                    },
2018                },
2019            });
2020            return;
2021        }
2022
2023        // Shading pattern stroke: clip to stroke outline, then emit shading.
2024        if let Some(shading_box) = self.gstate.stroke_shading_pattern.clone() {
2025            let mut sp = self.gstate.stroke_params_with_ctm();
2026            sp.ctm = ctm;
2027            // Expand the device-space bbox by half the stroke width to account
2028            // for the area the stroke outline covers beyond the centerline.
2029            let mut bbox = path_device_bbox(&path);
2030            let scale = self.gstate.ctm_scale_factor();
2031            let half_w = self.gstate.line_width * scale * 0.5;
2032            bbox[0] -= half_w;
2033            bbox[1] -= half_w;
2034            bbox[2] += half_w;
2035            bbox[3] += half_w;
2036            let mut group_dl = DisplayList::new();
2037            group_dl.push(DisplayElement::Clip {
2038                path: user_path,
2039                params: ClipParams {
2040                    fill_rule: FillRule::NonZeroWinding,
2041                    ctm: Matrix::identity(),
2042                    stroke_params: Some(sp),
2043                },
2044            });
2045            for elem in shading_box.0.elements() {
2046                group_dl.push(elem.clone());
2047            }
2048            self.display_list.push(DisplayElement::Group {
2049                elements: group_dl,
2050                params: GroupParams {
2051                    bbox,
2052                    isolated: true,
2053                    knockout: false,
2054                    blend_mode: self.gstate.blend_mode,
2055                    alpha: self.gstate.stroke_alpha,
2056                    color_space: stet_graphics::display_list::GroupColorSpace::Inherited,
2057                },
2058            });
2059            return;
2060        }
2061
2062        let mut params = self.gstate.stroke_params_with_ctm();
2063        params.ctm = ctm;
2064        self.display_list.push(DisplayElement::Stroke {
2065            path: user_path,
2066            params,
2067        });
2068    }
2069
2070    /// Emit fill+stroke atomically: when both are simple (no pattern/shading)
2071    /// and blend mode is Normal, wrap them in an isolated Group so the stroke
2072    /// erases the fill's AA edges inside the offscreen buffer before the
2073    /// combined result composites onto the page. This prevents the fill's
2074    /// anti-aliased edge pixels from leaking into the pixmap where a
2075    /// subsequent overprint knockout at a slightly different position would
2076    /// not fully cover them (GWG 4.0.1 swatch d).
2077    fn emit_fill_stroke(&mut self, path: PsPath, fill_rule: FillRule) {
2078        let is_simple_fill =
2079            self.gstate.fill_shading_pattern.is_none() && self.gstate.fill_pattern.is_none();
2080        let is_simple_stroke =
2081            self.gstate.stroke_shading_pattern.is_none() && self.gstate.stroke_pattern.is_none();
2082
2083        // Group wrapping is safe when the paint's rendering doesn't depend on
2084        // per-channel backdrop interaction that differs between a real page
2085        // backdrop and an isolated group's transparent backdrop.
2086        //
2087        // - Non-overprint paints always replace the backdrop: safe to group.
2088        // - Overprint paints can preserve backdrop channels (spot plates,
2089        //   zero-valued CMYK channels under strict OPM-1). To be safe we
2090        //   require BOTH fill and stroke to be DeviceCMYK (no Separation /
2091        //   DeviceN so spot plates aren't involved), the source to be white
2092        //   (0,0,0,0) so OPM-0 produces a full knockout regardless of
2093        //   backdrop, and NOT under strict OPM-1 (which would preserve zero
2094        //   channels and thus depend on the actual backdrop).
2095        let strict_opm1 = self.gstate.overprint_mode == 1 && self.gstate.opm_paired;
2096        let is_white_fill = self.gstate.fill_is_device_cmyk
2097            && self
2098                .gstate
2099                .fill_color
2100                .native_cmyk
2101                .map(|(c, m, y, k)| c == 0.0 && m == 0.0 && y == 0.0 && k == 0.0)
2102                .unwrap_or(false);
2103        let is_white_stroke = self.gstate.stroke_is_device_cmyk
2104            && self
2105                .gstate
2106                .stroke_color
2107                .native_cmyk
2108                .map(|(c, m, y, k)| c == 0.0 && m == 0.0 && y == 0.0 && k == 0.0)
2109                .unwrap_or(false);
2110        let has_any_overprint = self.gstate.overprint || self.gstate.overprint_stroke;
2111        // When any overprint is active, require BOTH sides to be DeviceCMYK so
2112        // spot colorants (which depend on multiplicative backdrop interaction
2113        // for correct rendering) aren't involved. Mixing a DeviceCMYK overprint
2114        // fill with a Separation stroke (GWG 4.0.1 swatches a/b/c) would lose
2115        // the spot backdrop inside the group.
2116        let both_device_cmyk = self.gstate.fill_is_device_cmyk && self.gstate.stroke_is_device_cmyk;
2117        let fill_overprint_safe =
2118            !self.gstate.overprint || (is_white_fill && both_device_cmyk && !strict_opm1);
2119        let stroke_overprint_safe =
2120            !self.gstate.overprint_stroke || (is_white_stroke && both_device_cmyk && !strict_opm1);
2121        // Extra guard: even if each side's own overprint check passes, refuse
2122        // when *any* overprint is active and the OTHER side isn't DeviceCMYK —
2123        // this catches the "inherited overprint_stroke=false + Separation
2124        // stroke + overprint fill" case where the fill alone looks safe.
2125        let mixed_space_with_overprint = has_any_overprint && !both_device_cmyk;
2126
2127        if is_simple_fill
2128            && is_simple_stroke
2129            && self.gstate.blend_mode == 0
2130            && fill_overprint_safe
2131            && stroke_overprint_safe
2132            && !mixed_space_with_overprint
2133        {
2134            let ctm = self.gstate.ctm;
2135
2136            let mut bbox = path_device_bbox(&path);
2137            let scale = self.gstate.ctm_scale_factor();
2138            let half_w = self.gstate.line_width * scale * 0.5;
2139            bbox[0] -= half_w;
2140            bbox[1] -= half_w;
2141            bbox[2] += half_w;
2142            bbox[3] += half_w;
2143
2144            let fill_elem = DisplayElement::Fill {
2145                path: path.clone(),
2146                params: self.gstate.fill_params(fill_rule),
2147            };
2148
2149            let user_path = if let Some(inv) = ctm.invert() {
2150                path.transform(&inv)
2151            } else {
2152                path.clone()
2153            };
2154            let mut stroke_params = self.gstate.stroke_params_with_ctm();
2155            stroke_params.ctm = ctm;
2156            let stroke_elem = DisplayElement::Stroke {
2157                path: user_path,
2158                params: stroke_params,
2159            };
2160
2161            let mut group_dl = DisplayList::new();
2162            group_dl.push(fill_elem);
2163            group_dl.push(stroke_elem);
2164
2165            self.display_list.push(DisplayElement::Group {
2166                elements: group_dl,
2167                params: stet_graphics::display_list::GroupParams {
2168                    bbox,
2169                    isolated: true,
2170                    knockout: false,
2171                    blend_mode: 0,
2172                    alpha: 1.0,
2173                    color_space: stet_graphics::display_list::GroupColorSpace::Inherited,
2174                },
2175            });
2176        } else {
2177            self.emit_fill(path.clone(), fill_rule);
2178            self.emit_stroke(path);
2179        }
2180    }
2181
2182    fn op_n(&mut self) -> Result<(), PdfError> {
2183        // n: end path (no paint) — used for clip-only paths
2184        let _path = self.take_path();
2185        self.apply_pending_clip();
2186        Ok(())
2187    }
2188
2189    // === Clipping operators ===
2190
2191    fn op_big_w(&mut self) -> Result<(), PdfError> {
2192        // W: clip (non-zero winding), deferred to next paint op
2193        self.gstate.pending_clip = Some((self.current_path.clone(), FillRule::NonZeroWinding));
2194        Ok(())
2195    }
2196
2197    fn op_big_w_star(&mut self) -> Result<(), PdfError> {
2198        // W*: clip (even-odd), deferred to next paint op
2199        self.gstate.pending_clip = Some((self.current_path.clone(), FillRule::EvenOdd));
2200        Ok(())
2201    }
2202
2203    // === Device color operators ===
2204
2205    fn op_big_g(&mut self) -> Result<(), PdfError> {
2206        // G gray: set stroke color to gray
2207        let g = self.pop_number()?;
2208        let (color, painted, is_cmyk) = self.gray_paint_for_gstate(g);
2209        self.gstate.stroke_color = color;
2210        self.gstate.stroke_color_space = ColorSpaceRef::DeviceGray;
2211        self.gstate.stroke_painted_channels = painted;
2212        self.gstate.stroke_is_device_cmyk = is_cmyk;
2213        self.gstate.stroke_is_none = false;
2214        self.gstate.stroke_spot_color = None;
2215        self.gstate.stroke_icc_color = None;
2216        self.gstate.stroke_pattern = None;
2217        self.gstate.stroke_shading_pattern = None;
2218        Ok(())
2219    }
2220
2221    fn op_small_g(&mut self) -> Result<(), PdfError> {
2222        // g gray: set fill color to gray
2223        let g = self.pop_number()?;
2224        let (color, painted, is_cmyk) = self.gray_paint_for_gstate(g);
2225        self.gstate.fill_color = color;
2226        self.gstate.fill_color_space = ColorSpaceRef::DeviceGray;
2227        self.gstate.fill_painted_channels = painted;
2228        self.gstate.fill_is_device_cmyk = is_cmyk;
2229        self.gstate.fill_is_none = false;
2230        self.gstate.fill_spot_color = None;
2231        self.gstate.fill_icc_color = None;
2232        self.gstate.fill_pattern = None;
2233        self.gstate.fill_shading_pattern = None;
2234        Ok(())
2235    }
2236
2237    /// Build the gstate fields for a DeviceGray paint. In a CMYK page group
2238    /// the gray is promoted to a K-only DeviceCMYK paint so the rendering
2239    /// pipeline composites it identically to `0 0 0 (1−g) k` — matches PDF/X
2240    /// semantics where DeviceGray maps onto the K plate (GWG 23.0). The
2241    /// promoted paint still carries `painted_channels = CMYK_K` so the
2242    /// overprint dispatcher routes it through the K-only subset path,
2243    /// preserving the overprint-vs-spot behaviour the standalone gray
2244    /// promotion (GWG 3.0 b/h) needs.
2245    fn gray_paint_for_gstate(&mut self, g: f64) -> (DeviceColor, u8, bool) {
2246        if self.pdfx_cmyk_intent && !self.in_smask_form {
2247            let k = (1.0 - g).clamp(0.0, 1.0);
2248            let color = DeviceColor::from_cmyk_icc(0.0, 0.0, 0.0, k, &mut self.icc_cache);
2249            (color, stet_graphics::device::CMYK_K, true)
2250        } else {
2251            (DeviceColor::from_gray(g), 0, false)
2252        }
2253    }
2254
2255    fn op_big_rg(&mut self) -> Result<(), PdfError> {
2256        // RG r g b: set stroke color to RGB
2257        let n = self.get_numbers(3)?;
2258        let (r, g, b) = self.cmyk_group_rgb(n[0], n[1], n[2]);
2259        self.gstate.stroke_color = DeviceColor::from_rgb(r, g, b);
2260        self.gstate.stroke_color_space = ColorSpaceRef::DeviceRGB;
2261        self.gstate.stroke_painted_channels = 0;
2262        self.gstate.stroke_is_device_cmyk = false;
2263        self.gstate.stroke_is_none = false;
2264        self.gstate.stroke_spot_color = None;
2265        self.gstate.stroke_icc_color = None;
2266        self.gstate.stroke_pattern = None;
2267        self.gstate.stroke_shading_pattern = None;
2268        Ok(())
2269    }
2270
2271    fn op_small_rg(&mut self) -> Result<(), PdfError> {
2272        // rg r g b: set fill color to RGB
2273        let n = self.get_numbers(3)?;
2274        let (r, g, b) = self.cmyk_group_rgb(n[0], n[1], n[2]);
2275        self.gstate.fill_color = DeviceColor::from_rgb(r, g, b);
2276        self.gstate.fill_color_space = ColorSpaceRef::DeviceRGB;
2277        self.gstate.fill_painted_channels = 0;
2278        self.gstate.fill_is_device_cmyk = false;
2279        self.gstate.fill_is_none = false;
2280        self.gstate.fill_spot_color = None;
2281        self.gstate.fill_icc_color = None;
2282        self.gstate.fill_pattern = None;
2283        self.gstate.fill_shading_pattern = None;
2284        Ok(())
2285    }
2286
2287    /// When the page group is DeviceCMYK, round-trip RGB through the CMYK
2288    /// profile to simulate compositing in CMYK space.
2289    fn cmyk_group_rgb(&mut self, r: f64, g: f64, b: f64) -> (f64, f64, f64) {
2290        if self.page_group_is_cmyk {
2291            if let Some(result) = self.icc_cache.round_trip_rgb_via_cmyk(r, g, b) {
2292                return result;
2293            }
2294        }
2295        (r, g, b)
2296    }
2297
2298    /// When the page group is DeviceCMYK, route a DeviceGray-derived image
2299    /// (DeviceGray itself, or Separation/DeviceN whose alt is DeviceGray)
2300    /// through the K plate so it composites equivalently to a DeviceCMYK
2301    /// 0/0/0/(1−g) image. Without this, GWG 23.0's "4 different Grays" test
2302    /// shows the right half of an X over its DeviceCMYK BG (left half comes
2303    /// from the polygon overpaint, fixed by `cmyk_group_promote_color`).
2304    ///
2305    /// Returns `(color_space, sample_data)` with the alt promoted to
2306    /// DeviceCMYK. For DeviceGray images we expand 1-byte gray samples to
2307    /// 4-byte CMYK with K=255−g; for Separation/DeviceN with DeviceGray alt
2308    /// we rebuild the tint table to emit `(0, 0, 0, 1−g)` instead of `g`.
2309    fn cmyk_group_promote_image(
2310        &self,
2311        cs: ImageColorSpace,
2312        data: Vec<u8>,
2313        width: u32,
2314        height: u32,
2315    ) -> (ImageColorSpace, Vec<u8>) {
2316        if !self.pdfx_cmyk_intent || self.in_smask_form {
2317            return (cs, data);
2318        }
2319        match cs {
2320            ImageColorSpace::DeviceGray => {
2321                let npx = (width as usize) * (height as usize);
2322                let take = npx.min(data.len());
2323                let mut new_data = vec![0u8; npx * 4];
2324                for i in 0..take {
2325                    new_data[i * 4 + 3] = 255 - data[i];
2326                }
2327                (ImageColorSpace::DeviceCMYK, new_data)
2328            }
2329            ImageColorSpace::Separation {
2330                name,
2331                alt_space,
2332                tint_table,
2333            } => {
2334                if matches!(alt_space.as_ref(), ImageColorSpace::DeviceGray)
2335                    && tint_table.num_outputs == 1
2336                {
2337                    let samples = tint_table.samples_per_dim as usize;
2338                    let mut new_data = Vec::with_capacity(samples * 4);
2339                    for i in 0..samples {
2340                        let g = tint_table.data[i] as f64;
2341                        let k = (1.0 - g).clamp(0.0, 1.0) as f32;
2342                        new_data.push(0.0);
2343                        new_data.push(0.0);
2344                        new_data.push(0.0);
2345                        new_data.push(k);
2346                    }
2347                    let promoted_table = TintLookupTable {
2348                        num_inputs: 1,
2349                        num_outputs: 4,
2350                        samples_per_dim: tint_table.samples_per_dim,
2351                        data: new_data,
2352                    };
2353                    (
2354                        ImageColorSpace::Separation {
2355                            name,
2356                            alt_space: Box::new(ImageColorSpace::DeviceCMYK),
2357                            tint_table: Arc::new(promoted_table),
2358                        },
2359                        data,
2360                    )
2361                } else {
2362                    (
2363                        ImageColorSpace::Separation {
2364                            name,
2365                            alt_space,
2366                            tint_table,
2367                        },
2368                        data,
2369                    )
2370                }
2371            }
2372            ImageColorSpace::DeviceN {
2373                names,
2374                alt_space,
2375                tint_table,
2376            } => {
2377                if matches!(alt_space.as_ref(), ImageColorSpace::DeviceGray)
2378                    && tint_table.num_outputs == 1
2379                {
2380                    let total = tint_table.data.len();
2381                    let mut new_data = Vec::with_capacity(total * 4);
2382                    for &g in &tint_table.data {
2383                        let k = (1.0 - g as f64).clamp(0.0, 1.0) as f32;
2384                        new_data.push(0.0);
2385                        new_data.push(0.0);
2386                        new_data.push(0.0);
2387                        new_data.push(k);
2388                    }
2389                    let promoted_table = TintLookupTable {
2390                        num_inputs: tint_table.num_inputs,
2391                        num_outputs: 4,
2392                        samples_per_dim: tint_table.samples_per_dim,
2393                        data: new_data,
2394                    };
2395                    (
2396                        ImageColorSpace::DeviceN {
2397                            names,
2398                            alt_space: Box::new(ImageColorSpace::DeviceCMYK),
2399                            tint_table: Arc::new(promoted_table),
2400                        },
2401                        data,
2402                    )
2403                } else {
2404                    (
2405                        ImageColorSpace::DeviceN {
2406                            names,
2407                            alt_space,
2408                            tint_table,
2409                        },
2410                        data,
2411                    )
2412                }
2413            }
2414            other => (other, data),
2415        }
2416    }
2417
2418    /// When the page group is DeviceCMYK, override a paint's RGB with the
2419    /// CMYK-ICC rendering of its `native_cmyk` whenever the source RGB is
2420    /// itself a flat gray and the CMYK is K-only — matches the PDF/X
2421    /// behaviour expected for Separation/DeviceN paints whose alternate space
2422    /// is DeviceGray (e.g. Separation /Black tinted via gray=1−tint).
2423    fn cmyk_group_promote_color(&mut self, color: &mut DeviceColor) {
2424        if !self.pdfx_cmyk_intent || self.in_smask_form {
2425            return;
2426        }
2427        let Some((c, m, y, k)) = color.native_cmyk else {
2428            return;
2429        };
2430        // Only fire for K-only CMYK whose r/g/b currently form a flat gray —
2431        // that's the DeviceGray-alt case. DeviceCMYK paints already have ICC-
2432        // converted RGB, so the override would be a no-op there; restricting
2433        // to flat-gray r/g/b avoids ever touching genuine colour paints.
2434        if !(c == 0.0 && m == 0.0 && y == 0.0) {
2435            return;
2436        }
2437        // Skip the all-zero CMYK case. It legitimately encodes "white" for a
2438        // DeviceCMYK 0/0/0/0 paint (where r/g/b is already 1/1/1, so the
2439        // override would be a no-op), but it also surfaces from the separation
2440        // handler's fallback for non-K process colorants like /All — there
2441        // `native_cmyk` is set to (0,0,0,0) even when the visual is the alt-
2442        // gray result (e.g. Separation /All at tint=1 → gray=0 → black text).
2443        // ICC-converting (0,0,0,0) would erase those paints to white.
2444        if k == 0.0 {
2445            return;
2446        }
2447        if (color.r - color.g).abs() > f64::EPSILON || (color.r - color.b).abs() > f64::EPSILON {
2448            return;
2449        }
2450        if let Some((r, g, b)) = self.icc_cache.convert_cmyk(0.0, 0.0, 0.0, k) {
2451            color.r = r;
2452            color.g = g;
2453            color.b = b;
2454        }
2455    }
2456
2457    fn op_big_k(&mut self) -> Result<(), PdfError> {
2458        // K c m y k: set stroke color to CMYK
2459        let n = self.get_numbers(4)?;
2460        self.gstate.stroke_color =
2461            DeviceColor::from_cmyk_icc(n[0], n[1], n[2], n[3], &mut self.icc_cache);
2462        self.gstate.stroke_color_space = ColorSpaceRef::DeviceCMYK;
2463        self.gstate.stroke_painted_channels = stet_graphics::device::CMYK_ALL;
2464        self.gstate.stroke_is_device_cmyk = true;
2465        self.gstate.stroke_is_none = false;
2466        self.gstate.stroke_spot_color = None;
2467        self.gstate.stroke_icc_color = None;
2468        self.gstate.stroke_pattern = None;
2469        self.gstate.stroke_shading_pattern = None;
2470        Ok(())
2471    }
2472
2473    fn op_small_k(&mut self) -> Result<(), PdfError> {
2474        // k c m y k: set fill color to CMYK
2475        let n = self.get_numbers(4)?;
2476        self.gstate.fill_color =
2477            DeviceColor::from_cmyk_icc(n[0], n[1], n[2], n[3], &mut self.icc_cache);
2478        self.gstate.fill_color_space = ColorSpaceRef::DeviceCMYK;
2479        self.gstate.fill_painted_channels = stet_graphics::device::CMYK_ALL;
2480        self.gstate.fill_is_device_cmyk = true;
2481        self.gstate.fill_is_none = false;
2482        self.gstate.fill_spot_color = None;
2483        self.gstate.fill_icc_color = None;
2484        self.gstate.fill_pattern = None;
2485        self.gstate.fill_shading_pattern = None;
2486        Ok(())
2487    }
2488
2489    // === General color operators ===
2490
2491    fn op_big_cs(&mut self) -> Result<(), PdfError> {
2492        // CS name: set stroke color space
2493        let name = self
2494            .operand_stack
2495            .last()
2496            .and_then(|o| o.as_name())
2497            .ok_or(PdfError::Other("CS: expected name".into()))?
2498            .to_vec();
2499        self.gstate.stroke_color_space = name_to_cs_ref(&name);
2500        Ok(())
2501    }
2502
2503    fn op_small_cs(&mut self) -> Result<(), PdfError> {
2504        // cs name: set fill color space
2505        let name = self
2506            .operand_stack
2507            .last()
2508            .and_then(|o| o.as_name())
2509            .ok_or(PdfError::Other("cs: expected name".into()))?
2510            .to_vec();
2511        self.gstate.fill_color_space = name_to_cs_ref(&name);
2512        Ok(())
2513    }
2514
2515    /// Resolve a color space from a ColorSpaceRef.
2516    /// For named color spaces, uses a HashMap index of the ColorSpace resource
2517    /// sub-dict to avoid O(n) linear scans on large dicts (74K+ entries).
2518    fn resolve_cs_cached(
2519        &mut self,
2520        cs_ref: &ColorSpaceRef,
2521    ) -> Result<ResolvedColorSpace, PdfError> {
2522        if let ColorSpaceRef::Named(name) = cs_ref {
2523            // Fast path for device color space names
2524            match name.as_slice() {
2525                b"DeviceGray" | b"G" => return Ok(ResolvedColorSpace::DeviceGray),
2526                b"DeviceRGB" | b"RGB" => return Ok(ResolvedColorSpace::DeviceRGB),
2527                b"DeviceCMYK" | b"CMYK" => return Ok(ResolvedColorSpace::DeviceCMYK),
2528                b"Pattern" => return Ok(ResolvedColorSpace::Pattern),
2529                _ => {}
2530            }
2531            // Build HashMap index of ColorSpace resource dict on first use
2532            if self.cs_index.is_none() {
2533                let mut index = std::collections::HashMap::new();
2534                if let Some(cs_dict) = self.resolve_resource_subdict(b"ColorSpace") {
2535                    for (k, v) in cs_dict.entries() {
2536                        index.insert(k.clone(), v.clone());
2537                    }
2538                }
2539                self.cs_index = Some(index);
2540            }
2541            if let Some(cs_obj) = self.cs_index.as_ref().unwrap().get(name.as_slice()) {
2542                let cs_obj = cs_obj.clone();
2543                resolve_color_space_obj(&cs_obj, self.resolver)
2544            } else {
2545                // Name not in cached ColorSpace dict — fall back to full resolution
2546                // (handles resources without a ColorSpace sub-dict, or names that
2547                // appear due to resource inheritance not captured by the index)
2548                resolve_color_space(
2549                    &ColorSpaceRef::Named(name.to_vec()),
2550                    &self.resources,
2551                    self.resolver,
2552                )
2553            }
2554        } else {
2555            resolve_color_space(cs_ref, &self.resources, self.resolver)
2556        }
2557    }
2558
2559    fn op_sc_stroke(&mut self) -> Result<(), PdfError> {
2560        // SC/SCN: set stroke color in current color space.
2561        // Some non-conforming PDFs use `/PatternName SCN` without first doing
2562        // `/Pattern CS` — detect a name operand and treat it as a pattern
2563        // reference regardless of the current color space.
2564        if matches!(self.operand_stack.last(), Some(Operand::Name(_))) {
2565            return self.handle_pattern_stroke();
2566        }
2567        let cs = self.resolve_cs_cached(&self.gstate.stroke_color_space.clone())?;
2568        if matches!(cs, ResolvedColorSpace::Pattern) {
2569            return self.handle_pattern_stroke();
2570        }
2571        let n = cs.num_components();
2572        if n == 0 {
2573            return Ok(());
2574        }
2575        let nums = self.get_numbers(n)?;
2576        self.gstate.stroke_painted_channels = painted_channels_for_cs(&cs);
2577        self.gstate.stroke_is_none = cs.is_none_colorant();
2578        self.gstate.stroke_is_device_cmyk = matches!(
2579            cs,
2580            ResolvedColorSpace::DeviceCMYK | ResolvedColorSpace::ICCBased { n: 4, .. }
2581        );
2582        let intent = self.gstate.rendering_intent;
2583        let mut color = color_space::components_to_device_color_icc_with_intent(
2584            &cs,
2585            &nums,
2586            Some(&mut self.icc_cache),
2587            intent,
2588        );
2589        self.cmyk_group_promote_color(&mut color);
2590        self.gstate.stroke_color = color;
2591        self.gstate.stroke_spot_color =
2592            color_space::build_spot_color(&cs, &nums, &mut self.spot_tint_table_cache);
2593        self.gstate.stroke_icc_color = color_space::build_icc_color(&cs, &nums);
2594        self.gstate.stroke_pattern = None;
2595        self.gstate.stroke_shading_pattern = None;
2596        Ok(())
2597    }
2598
2599    fn op_sc_fill(&mut self) -> Result<(), PdfError> {
2600        // sc/scn: set fill color in current color space.
2601        // Some non-conforming PDFs use `/PatternName scn` without first doing
2602        // `/Pattern cs` — detect a name operand and treat it as a pattern
2603        // reference regardless of the current color space (matches hayro/Firefox).
2604        if matches!(self.operand_stack.last(), Some(Operand::Name(_))) {
2605            return self.handle_pattern_fill();
2606        }
2607        let cs = self.resolve_cs_cached(&self.gstate.fill_color_space.clone())?;
2608        if matches!(cs, ResolvedColorSpace::Pattern) {
2609            return self.handle_pattern_fill();
2610        }
2611        let n = cs.num_components();
2612        if n == 0 {
2613            return Ok(());
2614        }
2615        let nums = self.get_numbers(n)?;
2616        self.gstate.fill_painted_channels = painted_channels_for_cs(&cs);
2617        self.gstate.fill_is_none = cs.is_none_colorant();
2618        self.gstate.fill_is_device_cmyk = matches!(
2619            cs,
2620            ResolvedColorSpace::DeviceCMYK | ResolvedColorSpace::ICCBased { n: 4, .. }
2621        );
2622        let intent = self.gstate.rendering_intent;
2623        let mut color = color_space::components_to_device_color_icc_with_intent(
2624            &cs,
2625            &nums,
2626            Some(&mut self.icc_cache),
2627            intent,
2628        );
2629        self.cmyk_group_promote_color(&mut color);
2630        self.gstate.fill_color = color;
2631        self.gstate.fill_spot_color =
2632            color_space::build_spot_color(&cs, &nums, &mut self.spot_tint_table_cache);
2633        self.gstate.fill_icc_color = color_space::build_icc_color(&cs, &nums);
2634        self.gstate.fill_pattern = None;
2635        self.gstate.fill_shading_pattern = None;
2636        Ok(())
2637    }
2638
2639    // === Text operators (state recording, Phase C will add rendering) ===
2640
2641    fn op_tf(&mut self) -> Result<(), PdfError> {
2642        // Tf font size
2643        let len = self.operand_stack.len();
2644        if len < 2 {
2645            return Ok(());
2646        }
2647        self.gstate.font_size = self.operand_stack[len - 1].as_f64().unwrap_or(12.0);
2648        if let Some(name) = self.operand_stack[len - 2].as_name() {
2649            let name = name.to_vec();
2650            self.gstate.text_font_name = name.clone();
2651            self.resolve_current_font(&name);
2652        }
2653        Ok(())
2654    }
2655
2656    /// Resolve the current font by name from the font cache or resources.
2657    fn resolve_current_font(&mut self, name: &[u8]) {
2658        // Check cache by name first (fast path for the common case where the
2659        // same name maps to the same font object across resource scopes).
2660        if let Some(cached) = self.font_cache.get(name) {
2661            // Verify the cached font still matches — different resource scopes
2662            // (page vs annotation vs form) may map the same name to different
2663            // font objects (e.g. /TT1 → obj 90 on page, /TT1 → obj 407 in annot).
2664            let font_ref = self
2665                .resolve_resource_subdict(b"Font")
2666                .and_then(|fd| fd.get(name).cloned());
2667            if let Some(PdfObj::Ref(obj_num, _)) = &font_ref {
2668                let obj_key = obj_num.to_le_bytes().to_vec();
2669                if let Some(obj_cached) = self.font_cache.get(&obj_key) {
2670                    // The obj-keyed cache has this font — use it (handles both
2671                    // same-as-name and different-from-name cases).
2672                    self.current_font = Some(Arc::clone(obj_cached));
2673                    return;
2674                }
2675                // obj key not in cache → this is a NEW font object that happens
2676                // to share a name with a previously cached font. Fall through to
2677                // resolve the new font instead of using the stale name-cached entry.
2678            } else {
2679                // No obj ref — use the name-cached font
2680                self.current_font = Some(Arc::clone(cached));
2681                return;
2682            }
2683        }
2684
2685        // Cache miss — look up in resources /Font dict
2686        let font_ref = self
2687            .resolve_resource_subdict(b"Font")
2688            .and_then(|fd| fd.get(name).cloned());
2689        let font_ref = match font_ref {
2690            Some(r) => r,
2691            None => {
2692                // Font resource missing — try loading a default substitution font
2693                if let Some(fallback) = font::fallback_font(self.font_provider.as_ref()) {
2694                    let arc = Arc::new(fallback);
2695                    self.font_cache.insert(name.to_vec(), Arc::clone(&arc));
2696                    self.current_font = Some(arc);
2697                } else {
2698                    self.current_font = None;
2699                }
2700                return;
2701            }
2702        };
2703
2704        // Check if this same object was already resolved under a different name
2705        if let PdfObj::Ref(obj_num, _) = &font_ref {
2706            let obj_key = obj_num.to_le_bytes().to_vec();
2707            if let Some(cached) = self.font_cache.get(&obj_key) {
2708                let arc = Arc::clone(cached);
2709                self.font_cache.insert(name.to_vec(), Arc::clone(&arc));
2710                self.current_font = Some(arc);
2711                return;
2712            }
2713        }
2714
2715        match font::resolve_font(self.resolver, &font_ref, self.font_provider.as_ref()) {
2716            Ok(font) => {
2717                let arc = Arc::new(font);
2718                // Cache under both the name and object number keys
2719                if let PdfObj::Ref(obj_num, _) = &font_ref {
2720                    self.font_cache
2721                        .insert(obj_num.to_le_bytes().to_vec(), Arc::clone(&arc));
2722                }
2723                self.font_cache.insert(name.to_vec(), Arc::clone(&arc));
2724                self.current_font = Some(arc);
2725            }
2726            Err(e) => {
2727                // Deduplicate warnings across pages (same font fails on every page)
2728                use std::sync::Mutex;
2729                static WARNED: Mutex<Vec<String>> = Mutex::new(Vec::new());
2730                let msg = format!("font /{}: {}", String::from_utf8_lossy(name), e);
2731                if let Ok(mut set) = WARNED.lock()
2732                    && !set.contains(&msg)
2733                {
2734                    eprintln!("warning: {msg}");
2735                    set.push(msg);
2736                }
2737                // Try fallback font on resolution failure too
2738                if let Some(fallback) = font::fallback_font(self.font_provider.as_ref()) {
2739                    let arc = Arc::new(fallback);
2740                    self.font_cache.insert(name.to_vec(), Arc::clone(&arc));
2741                    self.current_font = Some(arc);
2742                } else {
2743                    self.current_font = None;
2744                }
2745            }
2746        }
2747    }
2748
2749    /// Check if the current text position is outside the visible form area.
2750    /// If so, mark the BT block as culled to skip remaining text ops.
2751    fn check_text_cull(&mut self) {
2752        if let Some((y_lo, y_hi)) = self.form_cull_y {
2753            // Text Y in form coordinates is the ty component of the text matrix
2754            let text_y = self.gstate.text_matrix.ty;
2755            if text_y < y_lo || text_y > y_hi {
2756                self.bt_culled = true;
2757            }
2758        }
2759    }
2760
2761    fn op_td(&mut self) -> Result<(), PdfError> {
2762        let n = self.get_numbers(2)?;
2763        let m = Matrix::translate(n[0], n[1]);
2764        self.gstate.text_line_matrix = self.gstate.text_line_matrix.concat(&m);
2765        self.gstate.text_matrix = self.gstate.text_line_matrix;
2766        self.check_text_cull();
2767        Ok(())
2768    }
2769
2770    fn op_big_td(&mut self) -> Result<(), PdfError> {
2771        let n = self.get_numbers(2)?;
2772        self.gstate.text_leading = -n[1];
2773        let m = Matrix::translate(n[0], n[1]);
2774        self.gstate.text_line_matrix = self.gstate.text_line_matrix.concat(&m);
2775        self.gstate.text_matrix = self.gstate.text_line_matrix;
2776        self.check_text_cull();
2777        Ok(())
2778    }
2779
2780    fn op_tm(&mut self) -> Result<(), PdfError> {
2781        let n = self.get_numbers(6)?;
2782        let m = Matrix::new(n[0], n[1], n[2], n[3], n[4], n[5]);
2783        self.gstate.text_matrix = m;
2784        self.gstate.text_line_matrix = m;
2785        self.check_text_cull();
2786        Ok(())
2787    }
2788
2789    fn op_t_star(&mut self) -> Result<(), PdfError> {
2790        let leading = self.gstate.text_leading;
2791        let m = Matrix::translate(0.0, -leading);
2792        self.gstate.text_line_matrix = self.gstate.text_line_matrix.concat(&m);
2793        self.gstate.text_matrix = self.gstate.text_line_matrix;
2794        Ok(())
2795    }
2796
2797    // === Text rendering operators ===
2798
2799    fn op_tj(&mut self) -> Result<(), PdfError> {
2800        let text = match self.operand_stack.last() {
2801            Some(Operand::Str(s)) => s.clone(),
2802            _ => return Ok(()),
2803        };
2804        self.show_text(&text);
2805        Ok(())
2806    }
2807
2808    fn op_big_tj(&mut self) -> Result<(), PdfError> {
2809        let arr = match self.operand_stack.last() {
2810            Some(Operand::Array(a)) => a.clone(),
2811            _ => return Ok(()),
2812        };
2813        let vertical = self.current_font.as_ref().is_some_and(|f| f.wmode() == 1);
2814        for elem in &arr {
2815            match elem {
2816                PdfObj::Str(s) => self.show_text(s),
2817                PdfObj::Int(n) => {
2818                    let shift = -*n as f64 / 1000.0 * self.gstate.font_size;
2819                    let m = if vertical {
2820                        Matrix::translate(0.0, shift)
2821                    } else {
2822                        Matrix::translate(shift * self.gstate.horizontal_scaling, 0.0)
2823                    };
2824                    self.gstate.text_matrix = self.gstate.text_matrix.concat(&m);
2825                }
2826                PdfObj::Real(f) => {
2827                    let shift = -f / 1000.0 * self.gstate.font_size;
2828                    let m = if vertical {
2829                        Matrix::translate(0.0, shift)
2830                    } else {
2831                        Matrix::translate(shift * self.gstate.horizontal_scaling, 0.0)
2832                    };
2833                    self.gstate.text_matrix = self.gstate.text_matrix.concat(&m);
2834                }
2835                _ => {}
2836            }
2837        }
2838        Ok(())
2839    }
2840
2841    fn op_quote(&mut self) -> Result<(), PdfError> {
2842        // ': T* then Tj
2843        self.op_t_star()?;
2844        self.op_tj()
2845    }
2846
2847    fn op_dblquote(&mut self) -> Result<(), PdfError> {
2848        // ": set word_spacing, char_spacing, then T* + Tj
2849        let len = self.operand_stack.len();
2850        if len < 3 {
2851            return Ok(());
2852        }
2853        self.gstate.word_spacing = self.operand_stack[len - 3].as_f64().unwrap_or(0.0);
2854        self.gstate.char_spacing = self.operand_stack[len - 2].as_f64().unwrap_or(0.0);
2855        // The string is at len-1, which op_tj reads from last()
2856        self.op_t_star()?;
2857        self.op_tj()
2858    }
2859
2860    /// Render a text string by emitting glyph paths as Fill display elements.
2861    fn show_text(&mut self, text: &[u8]) {
2862        let font = match &self.current_font {
2863            Some(f) => Arc::clone(f),
2864            None => return,
2865        };
2866
2867        let font_size = self.gstate.font_size;
2868        let char_spacing = self.gstate.char_spacing;
2869        let word_spacing = self.gstate.word_spacing;
2870        let text_rise = self.gstate.text_rise;
2871        let th = self.gstate.horizontal_scaling;
2872        let font_matrix = font.font_matrix();
2873        let render_mode = self.gstate.text_rendering_mode;
2874
2875        if font.is_composite() {
2876            // Composite (CID) font: variable-width character codes
2877            // (most are 2-byte, but some CMaps define 1-byte codes for space etc.)
2878            let mut i = 0;
2879            while i < text.len() {
2880                let code_width = font.code_width(text[i]);
2881                if code_width == 1 {
2882                    // 1-byte code. Per PDF spec 9.3.3, word spacing applies to
2883                    // the single-byte character code 32 (SPACE) even in a
2884                    // composite font.
2885                    let raw_code = text[i] as u32;
2886                    let extra = if raw_code == 0x20 { word_spacing } else { 0.0 };
2887                    i += 1;
2888                    let cid = font.resolve_code_to_cid(raw_code) as u16;
2889                    self.render_cid_glyph(
2890                        &font,
2891                        cid,
2892                        font_size,
2893                        char_spacing,
2894                        th,
2895                        text_rise,
2896                        &font_matrix,
2897                        render_mode,
2898                        extra,
2899                    );
2900                } else if i + 1 >= text.len() {
2901                    // Incomplete trailing byte in 2-byte font — treat as WinAnsi
2902                    let byte = text[i];
2903                    i += 1;
2904                    self.render_unicode_glyph(
2905                        byte,
2906                        font_size,
2907                        char_spacing,
2908                        th,
2909                        text_rise,
2910                        &font_matrix,
2911                        render_mode,
2912                    );
2913                } else {
2914                    // Multi-byte code (2, 3, or 4 bytes from codespace ranges).
2915                    let width = code_width.min(text.len() - i);
2916                    let mut raw_code = 0u32;
2917                    for b in &text[i..i + width] {
2918                        raw_code = (raw_code << 8) | (*b as u32);
2919                    }
2920                    let cid = font.resolve_code_to_cid(raw_code) as u16;
2921                    // If the multi-byte code resolves to CID 0 (or the raw code
2922                    // itself for identity), the sequence may be invalid (e.g.
2923                    // UTF-8 continuation bytes out of range).  Try interpreting
2924                    // the first byte as a 1-byte code instead.
2925                    let (cid, consumed) = if cid == 0 || (cid == raw_code as u16 && width > 2) {
2926                        let byte_cid = font.resolve_code_to_cid(text[i] as u32) as u16;
2927                        if byte_cid != 0 && byte_cid != text[i] as u16 {
2928                            (byte_cid, 1)
2929                        } else {
2930                            (cid, width)
2931                        }
2932                    } else {
2933                        (cid, width)
2934                    };
2935                    // Word spacing applies only to SINGLE-byte code 32.
2936                    let extra = if consumed == 1 && text[i] == 0x20 {
2937                        word_spacing
2938                    } else {
2939                        0.0
2940                    };
2941                    i += consumed;
2942                    if font.has_cid_glyph(cid) {
2943                        // CID maps to a valid GID in the font
2944                        self.render_cid_glyph(
2945                            &font,
2946                            cid,
2947                            font_size,
2948                            char_spacing,
2949                            th,
2950                            text_rise,
2951                            &font_matrix,
2952                            render_mode,
2953                            extra,
2954                        );
2955                    } else {
2956                        // 2-byte CID has no glyph.  Some malformed PDFs encode
2957                        // single-byte CIDs in 2-byte Identity-H strings with a
2958                        // padding high byte (e.g. 0x20).  Try the low byte alone.
2959                        let lo_cid = (raw_code & 0xFF) as u16;
2960                        if lo_cid > 0 && font.has_cid_glyph(lo_cid) {
2961                            self.render_cid_glyph(
2962                                &font,
2963                                lo_cid,
2964                                font_size,
2965                                char_spacing,
2966                                th,
2967                                text_rise,
2968                                &font_matrix,
2969                                render_mode,
2970                                extra,
2971                            );
2972                        } else if raw_code <= 0xFF {
2973                            // Low code point with no CID glyph — malformed PDF mixing
2974                            // 1-byte WinAnsi text in a CID font.  Bypass the CID
2975                            // machinery and map each byte through WinAnsi→Unicode→cmap.
2976                            self.render_unicode_glyph(
2977                                text[i - 2],
2978                                font_size,
2979                                char_spacing,
2980                                th,
2981                                text_rise,
2982                                &font_matrix,
2983                                render_mode,
2984                            );
2985                            self.render_unicode_glyph(
2986                                text[i - 1],
2987                                font_size,
2988                                char_spacing,
2989                                th,
2990                                text_rise,
2991                                &font_matrix,
2992                                render_mode,
2993                            );
2994                        } else {
2995                            // CID glyph not available (e.g. substitute font for CJK).
2996                            // Use CID width for correct advancement; try Unicode for shape.
2997                            self.render_cid_glyph_unicode_fallback(
2998                                &font,
2999                                cid,
3000                                raw_code,
3001                                font_size,
3002                                char_spacing,
3003                                th,
3004                                text_rise,
3005                                &font_matrix,
3006                                render_mode,
3007                                extra,
3008                            );
3009                        }
3010                    }
3011                }
3012            }
3013        } else if font.is_type3() {
3014            // Type 3 font: each glyph is a content stream.
3015            // Widths are in glyph space — scale by font matrix to get text space.
3016            let fm = font.font_matrix();
3017            let visible = (render_mode & 3) != 3; // mode 3 = invisible
3018            for &byte in text {
3019                if visible {
3020                    self.show_type3_glyph(&font, byte);
3021                }
3022
3023                let w0_glyph = font.glyph_width(byte);
3024                let w0 = w0_glyph * fm.a;
3025                let mut tx = w0 * font_size + char_spacing;
3026                if byte == b' ' {
3027                    tx += word_spacing;
3028                }
3029                tx *= th;
3030                let advance = Matrix::translate(tx, 0.0);
3031                self.gstate.text_matrix = self.gstate.text_matrix.concat(&advance);
3032            }
3033        } else {
3034            // Simple font: 1-byte character codes
3035            for &byte in text {
3036                if let Some(glyph_path) = font.glyph_path(byte) {
3037                    let text_state_matrix =
3038                        Matrix::new(font_size * th, 0.0, 0.0, font_size, 0.0, text_rise);
3039                    let trm = self
3040                        .gstate
3041                        .ctm
3042                        .concat(&self.gstate.text_matrix)
3043                        .concat(&text_state_matrix)
3044                        .concat(&font_matrix);
3045
3046                    let device_path = glyph_path.transform(&trm);
3047                    if !device_path.is_empty() {
3048                        self.emit_text_glyph(device_path, render_mode);
3049                    }
3050                }
3051
3052                let w0 = font.glyph_width(byte);
3053                let mut tx = w0 * font_size + char_spacing;
3054                if byte == b' ' {
3055                    tx += word_spacing;
3056                }
3057                tx *= th;
3058                let advance = Matrix::translate(tx, 0.0);
3059                self.gstate.text_matrix = self.gstate.text_matrix.concat(&advance);
3060            }
3061        }
3062    }
3063
3064    /// Render a single CID glyph and advance the text position.
3065    fn render_cid_glyph(
3066        &mut self,
3067        font: &PdfFont,
3068        cid: u16,
3069        font_size: f64,
3070        char_spacing: f64,
3071        th: f64,
3072        text_rise: f64,
3073        font_matrix: &Matrix,
3074        render_mode: i32,
3075        extra_advance: f64,
3076    ) {
3077        let vertical = font.wmode() == 1;
3078        if let Some(glyph_path) = font.glyph_path_cid(cid) {
3079            let text_state_matrix = if vertical {
3080                // Vertical mode: use per-CID metrics (w1, v_x, v_y) from W2/DW2.
3081                // v_x/v_y define the position vector from horizontal to vertical origin.
3082                let [_w1, v_x, v_y] = font.vertical_metrics_cid(cid);
3083                Matrix::new(
3084                    font_size,
3085                    0.0,
3086                    0.0,
3087                    font_size,
3088                    -v_x / 1000.0 * font_size,
3089                    -v_y / 1000.0 * font_size,
3090                )
3091            } else {
3092                Matrix::new(font_size * th, 0.0, 0.0, font_size, 0.0, text_rise)
3093            };
3094            let trm = self
3095                .gstate
3096                .ctm
3097                .concat(&self.gstate.text_matrix)
3098                .concat(&text_state_matrix)
3099                .concat(font_matrix);
3100            let device_path = glyph_path.transform(&trm);
3101            if !device_path.is_empty() {
3102                self.emit_text_glyph(device_path, render_mode);
3103            }
3104        }
3105        if vertical {
3106            let [w1, _vx, _vy] = font.vertical_metrics_cid(cid);
3107            let ty = w1 / 1000.0 * font_size + char_spacing + extra_advance;
3108            let advance = Matrix::translate(0.0, ty);
3109            self.gstate.text_matrix = self.gstate.text_matrix.concat(&advance);
3110        } else {
3111            let w0 = font.glyph_width_cid(cid);
3112            let tx = (w0 * font_size + char_spacing + extra_advance) * th;
3113            let advance = Matrix::translate(tx, 0.0);
3114            self.gstate.text_matrix = self.gstate.text_matrix.concat(&advance);
3115        }
3116    }
3117
3118    /// Render a CID glyph using Unicode→cmap for the glyph shape but the CID
3119    /// width table for text advancement. Used for substitute fonts that don't
3120    /// have CID-to-GID mappings but can render via Unicode code points.
3121    fn render_cid_glyph_unicode_fallback(
3122        &mut self,
3123        font: &PdfFont,
3124        cid: u16,
3125        unicode: u32,
3126        font_size: f64,
3127        char_spacing: f64,
3128        th: f64,
3129        text_rise: f64,
3130        font_matrix: &Matrix,
3131        render_mode: i32,
3132        extra_advance: f64,
3133    ) {
3134        let vertical = font.wmode() == 1;
3135        // Try to render the glyph shape via Unicode mapping in the substitute font
3136        if let Some(glyph_path) = font.glyph_path_unicode(unicode as u16) {
3137            let text_state_matrix = if vertical {
3138                let [_w1, v_x, v_y] = font.vertical_metrics_cid(cid);
3139                Matrix::new(
3140                    font_size,
3141                    0.0,
3142                    0.0,
3143                    font_size,
3144                    -v_x / 1000.0 * font_size,
3145                    -v_y / 1000.0 * font_size,
3146                )
3147            } else {
3148                Matrix::new(font_size * th, 0.0, 0.0, font_size, 0.0, text_rise)
3149            };
3150            let trm = self
3151                .gstate
3152                .ctm
3153                .concat(&self.gstate.text_matrix)
3154                .concat(&text_state_matrix)
3155                .concat(font_matrix);
3156            let device_path = glyph_path.transform(&trm);
3157            if !device_path.is_empty() {
3158                self.emit_text_glyph(device_path, render_mode);
3159            }
3160        }
3161        if vertical {
3162            let [w1, _vx, _vy] = font.vertical_metrics_cid(cid);
3163            let ty = w1 / 1000.0 * font_size + char_spacing + extra_advance;
3164            let advance = Matrix::translate(0.0, ty);
3165            self.gstate.text_matrix = self.gstate.text_matrix.concat(&advance);
3166        } else {
3167            let w0 = font.glyph_width_cid(cid);
3168            let tx = (w0 * font_size + char_spacing + extra_advance) * th;
3169            let advance = Matrix::translate(tx, 0.0);
3170            self.gstate.text_matrix = self.gstate.text_matrix.concat(&advance);
3171        }
3172    }
3173
3174    /// Render a single glyph from a WinAnsi byte via Unicode→cmap, bypassing CID.
3175    /// Used for malformed PDFs that embed 1-byte literal strings in CID fonts.
3176    fn render_unicode_glyph(
3177        &mut self,
3178        byte: u8,
3179        font_size: f64,
3180        char_spacing: f64,
3181        th: f64,
3182        text_rise: f64,
3183        font_matrix: &Matrix,
3184        render_mode: i32,
3185    ) {
3186        let unicode = font::winansi_byte_to_unicode(byte);
3187        if let Some(glyph_path) = self
3188            .current_font
3189            .as_ref()
3190            .and_then(|f| f.glyph_path_unicode(unicode))
3191        {
3192            let text_state_matrix =
3193                Matrix::new(font_size * th, 0.0, 0.0, font_size, 0.0, text_rise);
3194            let trm = self
3195                .gstate
3196                .ctm
3197                .concat(&self.gstate.text_matrix)
3198                .concat(&text_state_matrix)
3199                .concat(font_matrix);
3200            let device_path = glyph_path.transform(&trm);
3201            if !device_path.is_empty() {
3202                self.emit_text_glyph(device_path, render_mode);
3203            }
3204        }
3205        let w0 = self
3206            .current_font
3207            .as_ref()
3208            .map(|f| f.glyph_width_unicode(unicode))
3209            .unwrap_or(0.0);
3210        let tx = (w0 * font_size + char_spacing) * th;
3211        let advance = Matrix::translate(tx, 0.0);
3212        self.gstate.text_matrix = self.gstate.text_matrix.concat(&advance);
3213    }
3214
3215    /// Render a single Type 3 glyph by interpreting its CharProc content stream.
3216    ///
3217    /// A CharProc may legitimately show text, including text in another Type 3
3218    /// font. Nothing stops it from showing *its own* glyph, so this path is
3219    /// bounded by [`MAX_CONTENT_NESTING`] like the Form XObject path: the
3220    /// increment around `interpret_stream` below is only load-bearing if
3221    /// somebody checks it.
3222    fn show_type3_glyph(&mut self, font: &PdfFont, char_code: u8) {
3223        if self.depth >= MAX_CONTENT_NESTING {
3224            return;
3225        }
3226        let proc_data = match font.type3_char_proc(char_code) {
3227            Some(data) => data.to_vec(),
3228            None => return,
3229        };
3230        let resources = match font.type3_resources() {
3231            Some(r) => r.clone(),
3232            None => return,
3233        };
3234
3235        let font_size = self.gstate.font_size;
3236        let text_rise = self.gstate.text_rise;
3237        let font_matrix = font.font_matrix();
3238
3239        // Build the text rendering matrix: CTM × Tm × [fontSize*Th 0 0 fontSize 0 rise] × FontMatrix
3240        let th = self.gstate.horizontal_scaling;
3241        let text_state_matrix = Matrix::new(font_size * th, 0.0, 0.0, font_size, 0.0, text_rise);
3242        let trm = self
3243            .gstate
3244            .ctm
3245            .concat(&self.gstate.text_matrix)
3246            .concat(&text_state_matrix)
3247            .concat(&font_matrix);
3248        // Interpret the CharProc stream with TRM as the CTM.
3249        // Save current state and swap in a fresh display list.
3250        let stack_depth_before = self.gstate_stack.len();
3251        self.gstate_stack.push(self.gstate.clone());
3252        // Merge the Type 3 font's resources with the page resources: the font's
3253        // own entries take priority (e.g. XObjects for emoji glyphs), but missing
3254        // categories fall back to the page resources (e.g. fonts referenced by
3255        // CharProcs that don't declare their own /Font sub-dict).
3256        let mut merged = self.resources.clone();
3257        for (key, value) in resources.entries() {
3258            merged.insert(key.clone(), value.clone());
3259        }
3260        let saved_resources = std::mem::replace(&mut self.resources, merged);
3261        let saved_display_list = std::mem::take(&mut self.display_list);
3262        let saved_path = std::mem::take(&mut self.current_path);
3263        let saved_point = self.current_point.take();
3264        let saved_subpath = self.subpath_start.take();
3265        let saved_font = self.current_font.clone();
3266        let saved_in_text = self.in_text;
3267        let saved_content_stream_ctm = self.content_stream_ctm;
3268        let saved_mc_stack = std::mem::take(&mut self.mc_stack);
3269
3270        self.gstate.ctm = trm;
3271        // Pattern Matrix maps pattern space to the "default coordinate system
3272        // of the content stream" — for Type 3 CharProcs, that's the TRM.
3273        self.content_stream_ctm = trm;
3274
3275        let saved_d1 = self.d1_color_suppressed;
3276        self.d1_color_suppressed = false;
3277        self.depth += 1;
3278        let _ = self.interpret_stream(&proc_data);
3279        self.depth -= 1;
3280        self.d1_color_suppressed = saved_d1;
3281        // Collect glyph display elements and append to main display list
3282        let glyph_elements = std::mem::replace(&mut self.display_list, saved_display_list);
3283        self.resources = saved_resources;
3284        self.current_path = saved_path;
3285        self.current_point = saved_point;
3286        self.subpath_start = saved_subpath;
3287        self.current_font = saved_font;
3288        self.in_text = saved_in_text;
3289        self.content_stream_ctm = saved_content_stream_ctm;
3290        self.mc_stack = saved_mc_stack;
3291        // Restore state: truncate any extra gstate_stack entries left by
3292        // unmatched q/Q inside the CharProc (e.g., if the EI parser consumed Q).
3293        self.gstate_stack.truncate(stack_depth_before + 1);
3294        if let Some(saved) = self.gstate_stack.pop() {
3295            self.gstate = saved;
3296        }
3297
3298        // Append all glyph elements to the main display list
3299        for elem in glyph_elements.into_elements() {
3300            self.display_list.push(elem);
3301        }
3302    }
3303
3304    /// Emit a text glyph to the display list based on the text rendering mode.
3305    ///
3306    /// Modes: 0=fill, 1=stroke, 2=fill+stroke, 3=invisible,
3307    ///        4-7=same as 0-3 but add to clipping path (clipping not yet implemented).
3308    fn emit_text_glyph(&mut self, device_path: PsPath, render_mode: i32) {
3309        let mode = render_mode & 3; // strip clip bit
3310        let clip = render_mode & 4 != 0; // bit 2 = add to text clip
3311
3312        match mode {
3313            0 => {
3314                // Fill only
3315                self.emit_text_fill(device_path.clone());
3316            }
3317            1 => {
3318                // Stroke only
3319                self.emit_text_stroke(device_path.clone());
3320            }
3321            2 => {
3322                // Fill then stroke
3323                self.emit_text_fill(device_path.clone());
3324                self.emit_text_stroke(device_path.clone());
3325            }
3326            _ => {} // mode 3 = invisible
3327        }
3328
3329        // Modes 4-7: accumulate glyph path into text clip
3330        if clip {
3331            let tcp = self.text_clip_path.get_or_insert_with(PsPath::new);
3332            tcp.segments.extend_from_slice(&device_path.segments);
3333        }
3334    }
3335
3336    /// Emit a text glyph fill, handling shading patterns, tiling patterns,
3337    /// or solid color fills.
3338    fn emit_text_fill(&mut self, path: PsPath) {
3339        if let Some(shading_box) = self.gstate.fill_shading_pattern.clone() {
3340            // PatternType 2 (shading pattern): clip to glyph path, emit shading.
3341            // Wrap in a Group to scope the clip.
3342            let bbox = path_device_bbox(&path);
3343            let mut group_dl = DisplayList::new();
3344            group_dl.push(DisplayElement::Clip {
3345                path,
3346                params: ClipParams {
3347                    fill_rule: FillRule::NonZeroWinding,
3348                    ctm: Matrix::identity(),
3349                    stroke_params: None,
3350                },
3351            });
3352            for elem in shading_box.0.elements() {
3353                group_dl.push(elem.clone());
3354            }
3355            self.display_list.push(DisplayElement::Group {
3356                elements: group_dl,
3357                params: GroupParams {
3358                    bbox,
3359                    isolated: true,
3360                    knockout: false,
3361                    blend_mode: self.gstate.blend_mode,
3362                    alpha: self.gstate.fill_alpha,
3363                    color_space: stet_graphics::display_list::GroupColorSpace::Inherited,
3364                },
3365            });
3366        } else if let Some(pattern) = self.gstate.fill_pattern.clone() {
3367            self.display_list.push(DisplayElement::PatternFill {
3368                params: PatternFillParams {
3369                    path,
3370                    fill_rule: FillRule::NonZeroWinding,
3371                    tile: pattern.tile,
3372                    pattern_matrix: pattern.pattern_matrix,
3373                    bbox: pattern.bbox,
3374                    xstep: pattern.x_step,
3375                    ystep: pattern.y_step,
3376                    paint_type: pattern.paint_type,
3377                    underlying_color: if pattern.paint_type == 2 {
3378                        Some(self.gstate.fill_color.clone())
3379                    } else {
3380                        None
3381                    },
3382                    pattern_id: pattern.pattern_id,
3383                    device_space_tile: false,
3384                    flip_tile_y: false,
3385                    stroke_params: None,
3386                    overprint_mode: if self.gstate.overprint {
3387                        self.gstate.overprint_mode
3388                    } else {
3389                        0
3390                    },
3391                },
3392            });
3393        } else {
3394            let mut params = self.gstate.fill_params(FillRule::NonZeroWinding);
3395            params.is_text_glyph = true;
3396            self.display_list
3397                .push(DisplayElement::Fill { path, params });
3398        }
3399    }
3400
3401    /// Emit a text glyph stroke, handling shading patterns, tiling patterns,
3402    /// or solid color strokes.
3403    fn emit_text_stroke(&mut self, path: PsPath) {
3404        // Shading pattern stroke: clip to stroked outline of glyph, emit shading.
3405        if let Some(shading_box) = self.gstate.stroke_shading_pattern.clone() {
3406            let mut sp = self.gstate.stroke_params();
3407            sp.is_text_glyph = true;
3408            // Expand bbox by half the (already device-scaled) stroke width.
3409            let mut bbox = path_device_bbox(&path);
3410            let half_w = sp.line_width * 0.5;
3411            bbox[0] -= half_w;
3412            bbox[1] -= half_w;
3413            bbox[2] += half_w;
3414            bbox[3] += half_w;
3415            let mut group_dl = DisplayList::new();
3416            group_dl.push(DisplayElement::Clip {
3417                path,
3418                params: ClipParams {
3419                    fill_rule: FillRule::NonZeroWinding,
3420                    ctm: Matrix::identity(),
3421                    stroke_params: Some(sp),
3422                },
3423            });
3424            for elem in shading_box.0.elements() {
3425                group_dl.push(elem.clone());
3426            }
3427            self.display_list.push(DisplayElement::Group {
3428                elements: group_dl,
3429                params: GroupParams {
3430                    bbox,
3431                    isolated: true,
3432                    knockout: false,
3433                    blend_mode: self.gstate.blend_mode,
3434                    alpha: self.gstate.stroke_alpha,
3435                    color_space: stet_graphics::display_list::GroupColorSpace::Inherited,
3436                },
3437            });
3438            return;
3439        }
3440
3441        // Tiling pattern stroke: emit PatternFill with stroke_params so the
3442        // renderer tiles the pattern over the stroked outline of the glyph.
3443        if let Some(pattern) = self.gstate.stroke_pattern.clone() {
3444            let mut sp = self.gstate.stroke_params();
3445            sp.is_text_glyph = true;
3446            self.display_list.push(DisplayElement::PatternFill {
3447                params: PatternFillParams {
3448                    path,
3449                    fill_rule: FillRule::NonZeroWinding,
3450                    tile: pattern.tile,
3451                    pattern_matrix: pattern.pattern_matrix,
3452                    bbox: pattern.bbox,
3453                    xstep: pattern.x_step,
3454                    ystep: pattern.y_step,
3455                    paint_type: pattern.paint_type,
3456                    underlying_color: if pattern.paint_type == 2 {
3457                        Some(self.gstate.stroke_color.clone())
3458                    } else {
3459                        None
3460                    },
3461                    pattern_id: pattern.pattern_id,
3462                    device_space_tile: false,
3463                    flip_tile_y: false,
3464                    stroke_params: Some(sp),
3465                    overprint_mode: if self.gstate.overprint {
3466                        self.gstate.overprint_mode
3467                    } else {
3468                        0
3469                    },
3470                },
3471            });
3472            return;
3473        }
3474
3475        let mut params = self.gstate.stroke_params();
3476        params.is_text_glyph = true;
3477        self.display_list
3478            .push(DisplayElement::Stroke { path, params });
3479    }
3480
3481    // === Marked content operators ===
3482
3483    /// Handle BDC (begin marked content with properties). Checks for Optional
3484    /// Content Group references and wraps content inside OCG blocks in an
3485    /// `OcgGroup` display list element for deferred visibility filtering at
3486    /// render time. Every BDC pushes a frame onto `mc_stack` — only `/OC`
3487    /// BDCs are `Ocg` frames; other BDCs are `Other` placeholders so the
3488    /// stack stays balanced with EMC.
3489    fn op_bdc(&mut self) -> Result<(), PdfError> {
3490        let props = self.operand_stack.pop();
3491        let tag = self.operand_stack.pop();
3492
3493        let is_oc = matches!(&tag, Some(Operand::Name(n)) if n == b"OC");
3494        if !is_oc {
3495            self.mc_stack.push(MarkedContentFrame::Other);
3496            return Ok(());
3497        }
3498
3499        // The properties operand is a name referencing /Properties in resources
3500        let mut pushed = false;
3501        if let Some(Operand::Name(prop_name)) = props
3502            && let Some(props_dict) = self.resolve_resource_subdict(b"Properties")
3503            && let Some(ocg_obj) = props_dict.get(&prop_name)
3504        {
3505            let visibility = self.build_visibility(ocg_obj);
3506            let parent_list = std::mem::replace(&mut self.display_list, DisplayList::new());
3507            self.mc_stack.push(MarkedContentFrame::Ocg {
3508                parent_list,
3509                visibility,
3510            });
3511            pushed = true;
3512        }
3513        if !pushed {
3514            // OC BDC without a resolvable properties entry still opens a
3515            // section that EMC must close.
3516            self.mc_stack.push(MarkedContentFrame::Other);
3517        }
3518
3519        Ok(())
3520    }
3521
3522    /// Check whether an OCG/OCMD reference is OFF.
3523    /// Handles both direct OCG references (`/Type /OCG`) and OCMD wrappers
3524    /// (`/Type /OCMD` with `/OCGs` array and optional `/P` visibility policy).
3525    fn is_ocg_off(&self, ocg_obj: &PdfObj) -> bool {
3526        // Direct OCG reference — check its object number
3527        if let Some((obj_num, _)) = ocg_obj.as_ref() {
3528            // Dereference to see if it's an OCMD wrapper
3529            if let Ok(resolved) = self.resolver.deref(ocg_obj) {
3530                if let Some(dict) = resolved.as_dict() {
3531                    if dict.get_name(b"Type") == Some(b"OCMD") {
3532                        return self.is_ocmd_off(dict);
3533                    }
3534                }
3535            }
3536            // Plain OCG reference
3537            return self.ocg_off.contains(&obj_num);
3538        }
3539        // Inline dict (unusual but possible)
3540        if let Some(dict) = ocg_obj.as_dict() {
3541            if dict.get_name(b"Type") == Some(b"OCMD") {
3542                return self.is_ocmd_off(dict);
3543            }
3544        }
3545        false
3546    }
3547
3548    /// Build an [`OcgVisibility`] from an `/OC BDC` properties reference
3549    /// or an XObject `/OC` entry.
3550    ///
3551    /// - Direct OCG ref → [`OcgVisibility::Single`] driven by
3552    ///   `/OCProperties /D /OFF` membership.
3553    /// - OCMD with `/VE` → [`OcgVisibility::Expression`].
3554    /// - OCMD with `/OCGs` (and optional `/P`) →
3555    ///   [`OcgVisibility::Membership`] with the parsed policy.
3556    /// - Unparseable shape → `Single { ocg_id: 0, default_visible:
3557    ///   <baked> }` so the renderer falls back to the document's
3558    ///   static evaluation.
3559    fn build_visibility(&self, ocg_obj: &PdfObj) -> OcgVisibility {
3560        let resolved = self.resolver.deref(ocg_obj).ok();
3561        let dict = resolved.as_ref().and_then(|o| o.as_dict());
3562
3563        if let Some(dict) = dict
3564            && dict.get_name(b"Type") == Some(b"OCMD")
3565        {
3566            // Default-visible for the whole OCMD = its static
3567            // evaluation under the document's default config. Acts as
3568            // the fast-path return value of `LayerSet::evaluate` when
3569            // no leaf has been overridden.
3570            let default_visible = !self.is_ocg_off(ocg_obj);
3571            // OCMD parsing produces Membership or Expression. We
3572            // don't have a `WarningSink` plumbed through the content
3573            // stream pipeline yet, so route warnings into a local
3574            // throwaway sink — `/VE` malformations are infrequent and
3575            // already fall back to AnyOn membership.
3576            let throwaway = crate::diagnostics::WarningSink::new();
3577            return crate::layers::ocmd::build_ocmd_visibility(
3578                self.resolver,
3579                dict,
3580                default_visible,
3581                &throwaway,
3582            );
3583        }
3584
3585        if let Some((ocg_id, _)) = ocg_obj.as_ref() {
3586            return OcgVisibility::Single {
3587                ocg_id,
3588                default_visible: !self.ocg_off.contains(&ocg_id),
3589            };
3590        }
3591
3592        // Inline OCG dict or other unparseable shape.
3593        OcgVisibility::Single {
3594            ocg_id: 0,
3595            default_visible: !self.is_ocg_off(ocg_obj),
3596        }
3597    }
3598
3599    /// Evaluate an OCMD (Optional Content Membership Dictionary).
3600    /// `/P` policy: AnyOn (default) = visible if ANY listed OCG is on;
3601    /// AnyOff = visible if ANY is off; AllOn = visible if ALL are on;
3602    /// AllOff = visible if ALL are off.
3603    fn is_ocmd_off(&self, ocmd: &PdfDict) -> bool {
3604        let policy = ocmd.get_name(b"P").unwrap_or(b"AnyOn");
3605
3606        // Collect OCG object numbers from /OCGs (may be a single ref or array)
3607        let mut ocg_nums = Vec::new();
3608        if let Some(ocgs_obj) = ocmd.get(b"OCGs") {
3609            match ocgs_obj {
3610                PdfObj::Ref(num, _) => ocg_nums.push(*num),
3611                PdfObj::Array(arr) => {
3612                    for item in arr {
3613                        if let Some((num, _)) = item.as_ref() {
3614                            ocg_nums.push(num);
3615                        }
3616                    }
3617                }
3618                _ => {}
3619            }
3620        }
3621        if ocg_nums.is_empty() {
3622            return false;
3623        }
3624
3625        // Evaluate visibility based on policy, then return whether suppressed
3626        let visible = match policy {
3627            b"AllOn" => ocg_nums.iter().all(|n| !self.ocg_off.contains(n)),
3628            b"AnyOff" => ocg_nums.iter().any(|n| self.ocg_off.contains(n)),
3629            b"AllOff" => ocg_nums.iter().all(|n| self.ocg_off.contains(n)),
3630            _ /* AnyOn */ => ocg_nums.iter().any(|n| !self.ocg_off.contains(n)),
3631        };
3632        !visible
3633    }
3634
3635    // === XObject operator ===
3636
3637    fn op_do(&mut self) -> Result<(), PdfError> {
3638        let name = self
3639            .operand_stack
3640            .last()
3641            .and_then(|o| o.as_name())
3642            .ok_or(PdfError::Other("Do: expected name".into()))?
3643            .to_vec();
3644
3645        // Look up XObject in resources (may be an indirect reference)
3646        let xobj_dict = self
3647            .resolve_resource_subdict(b"XObject")
3648            .ok_or(PdfError::Other("no XObject resources".into()))?;
3649        let xobj_ref = xobj_dict.get(&name).ok_or_else(|| {
3650            PdfError::Other(format!(
3651                "XObject /{} not found",
3652                String::from_utf8_lossy(&name)
3653            ))
3654        })?;
3655        // Keep the original ref for stream_data_from_obj (needed for encryption)
3656        let xobj_ref_clone = xobj_ref.clone();
3657        let xobj = self.resolver.deref(xobj_ref)?;
3658        let dict = xobj
3659            .as_dict()
3660            .ok_or(PdfError::Other("XObject is not a stream".into()))?;
3661
3662        // Check Optional Content visibility on the XObject itself.
3663        // Instead of suppressing content, wrap it in an OcgGroup so visibility
3664        // can be toggled at render time.
3665        let xobj_visibility = dict.get(b"OC").map(|oc_obj| self.build_visibility(oc_obj));
3666
3667        let mut wrapped = false;
3668        if let Some(visibility) = xobj_visibility {
3669            let parent_list = std::mem::replace(&mut self.display_list, DisplayList::new());
3670            self.mc_stack.push(MarkedContentFrame::Ocg {
3671                parent_list,
3672                visibility,
3673            });
3674            wrapped = true;
3675        }
3676
3677        let subtype = dict.get_name(b"Subtype").unwrap_or(b"");
3678        match subtype {
3679            b"Image" => self.handle_image_xobject(&xobj_ref_clone, dict)?,
3680            b"Form" => self.handle_form_xobject(&xobj_ref_clone, dict)?,
3681            _ => {}
3682        }
3683
3684        // Close the XObject OCG wrapper if one was opened.
3685        if wrapped {
3686            if let Some(MarkedContentFrame::Ocg {
3687                parent_list,
3688                visibility,
3689            }) = self.mc_stack.pop()
3690            {
3691                let ocg_list = std::mem::replace(&mut self.display_list, parent_list);
3692                self.display_list.push(DisplayElement::OcgGroup {
3693                    elements: ocg_list,
3694                    visibility,
3695                });
3696            }
3697        }
3698
3699        Ok(())
3700    }
3701
3702    /// Handle an Image XObject.
3703    fn handle_image_xobject(&mut self, obj: &PdfObj, dict: &PdfDict) -> Result<(), PdfError> {
3704        // Check image cache: if we've already processed this XObject, reuse the
3705        // decoded data with fresh graphics-state params (CTM, alpha, blend, etc.).
3706        if let PdfObj::Ref(obj_num, _) = obj {
3707            if let Some(cached) = self.image_cache.get(obj_num).cloned() {
3708                return self.emit_cached_image(cached);
3709            }
3710        }
3711
3712        // Width/Height may be indirect references in some PDFs.
3713        //
3714        // Both are validated before the cast rather than after: every buffer
3715        // size and loop bound below derives from them, and an `as u32` on an
3716        // unchecked file integer both truncates (`/Width 4294967297` becomes
3717        // 1) and lets `width * height` wrap.
3718        let width = validate_image_dimension(self.resolve_dict_int(dict, b"Width"))
3719            .ok_or(PdfError::Other("image has missing or invalid Width".into()))?;
3720        let height = validate_image_dimension(self.resolve_dict_int(dict, b"Height")).ok_or(
3721            PdfError::Other("image has missing or invalid Height".into()),
3722        )?;
3723        validate_image_size(width, height)
3724            .ok_or(PdfError::Other("image dimensions too large".into()))?;
3725
3726        // Check for image mask (1-bit stencil painted with current fill color)
3727        let is_image_mask = dict
3728            .get(b"ImageMask")
3729            .and_then(|o| match o {
3730                PdfObj::Bool(b) => Some(*b),
3731                _ => None,
3732            })
3733            .unwrap_or(false);
3734
3735        let bpc = if is_image_mask {
3736            1
3737        } else {
3738            validate_bits_per_component(dict.get_int(b"BitsPerComponent"))
3739                .ok_or(PdfError::Other("image has invalid BitsPerComponent".into()))?
3740        };
3741
3742        // Per ISO 32000 §11.3.4 a per-image `/Intent` overrides the gstate
3743        // `/RI`. GWG 17.2 (JPEG2000 + ICCBasedRGB) calibrates an Adobe RGB
3744        // image colour against a CMYK swatch under `/RelativeColorimetric`;
3745        // ignoring this override forces the proofing chain through the
3746        // gstate's default Perceptual intent and produces a visibly
3747        // different sRGB → the test's "X marker" appears.
3748        let gstate_intent = self.gstate.rendering_intent;
3749        let image_intent = match dict
3750            .get(b"Intent")
3751            .and_then(|o| self.resolver.deref(o).ok())
3752        {
3753            Some(PdfObj::Name(n)) => match n.as_slice() {
3754                b"Perceptual" => 0u8,
3755                b"RelativeColorimetric" => 1,
3756                b"Saturation" => 2,
3757                b"AbsoluteColorimetric" => 3,
3758                _ => gstate_intent,
3759            },
3760            _ => gstate_intent,
3761        };
3762
3763        // Resolve color space
3764        let has_explicit_cs = dict.get(b"ColorSpace").is_some();
3765        let resolved_cs = if is_image_mask {
3766            None
3767        } else if let Some(cs_obj) = dict.get(b"ColorSpace") {
3768            match resolve_color_space_obj(cs_obj, self.resolver) {
3769                Ok(cs) => Some(cs),
3770                Err(_) => {
3771                    // Damaged PDF: color space reference is invalid (e.g. points to
3772                    // an XRef stream in a corrupt linearized file). Fall back to a
3773                    // device color space based on BPC/component count heuristics.
3774                    Some(match bpc {
3775                        1 => ResolvedColorSpace::DeviceGray,
3776                        _ => ResolvedColorSpace::DeviceRGB,
3777                    })
3778                }
3779            }
3780        } else {
3781            // No ColorSpace in dict — will be inferred from JPX data below
3782            Some(ResolvedColorSpace::DeviceRGB)
3783        };
3784
3785        let polarity = if is_image_mask {
3786            if let Some(arr) = dict.get_array(b"Decode") {
3787                let vals: Vec<f64> = arr.iter().filter_map(|o| o.as_f64()).collect();
3788                vals.len() >= 2 && vals[0] > 0.5
3789            } else {
3790                false
3791            }
3792        } else {
3793            false
3794        };
3795
3796        // SMaskInData: JPX streams can embed an alpha channel.
3797        // 0 (default) = no embedded mask, 1 or 2 = alpha channel present in JP2 data.
3798        let smask_in_data = dict.get_int(b"SMaskInData").unwrap_or(0);
3799
3800        let filter_name_raw = dict.get_name(b"Filter");
3801        let filter_is_dct = matches!(filter_name_raw, Some(b"DCTDecode" | b"DCT"));
3802        // JPXDecode may be a single filter name or inside a filter array
3803        let filter_is_jpx = matches!(filter_name_raw, Some(b"JPXDecode" | b"JPX"))
3804            || dict.get_array(b"Filter").is_some_and(|arr| {
3805                arr.iter()
3806                    .any(|f| matches!(f.as_name(), Some(b"JPXDecode" | b"JPX")))
3807            });
3808
3809        // Decode the stream data. For DCTDecode with a bogus SOF height
3810        // (streaming encoder placeholder like 60000), patch the JPEG header
3811        // to the PDF dict height before decoding to avoid wasting time on
3812        // excess zero-filled rows.
3813        let cs_is_indexed = matches!(resolved_cs, Some(ResolvedColorSpace::Indexed { .. }));
3814        let sample_data = if filter_is_dct {
3815            if let Some(raw) = self.resolver.raw_stream_bytes(obj)
3816                && let Some((_jw, jh)) = crate::filters::jpeg_dimensions(raw)
3817                && jh > height * 2
3818            {
3819                let mut patched = raw.to_vec();
3820                crate::filters::patch_jpeg_sof_height(&mut patched, height as u16);
3821                crate::filters::decode_stream(
3822                    &patched,
3823                    &[crate::filters::Filter::DCTDecode],
3824                    &[],
3825                    None,
3826                )?
3827            } else {
3828                self.resolver.stream_data_from_obj(obj)?
3829            }
3830        } else if filter_is_jpx && cs_is_indexed {
3831            // JPXDecode + PDF Indexed color space: skip JP2-internal palette
3832            // resolution so the PDF's own lookup table handles depalettization.
3833            // Some JP2 files declare wrong palette column precision (e.g. 4-bit
3834            // for 8-bit values), causing hayro's palette expansion to corrupt colors.
3835            #[cfg(feature = "jpx")]
3836            {
3837                if let Some(raw) = self.resolver.raw_stream_bytes(obj) {
3838                    let jp2_data = crate::filters::decode_pre_jpx(raw, dict);
3839                    let (mut data, bpc) = crate::filters::decode_jpx_no_palette(&jp2_data)?;
3840                    // hayro normalizes sub-8-bit data to 0-255 grayscale.
3841                    // Un-normalize back to raw palette indices using the
3842                    // codestream's original bit depth.
3843                    if bpc < 8 {
3844                        let max_val = ((1u32 << bpc) - 1) as f64;
3845                        for b in data.iter_mut() {
3846                            *b = (*b as f64 / 255.0 * max_val).round() as u8;
3847                        }
3848                    }
3849                    data
3850                } else {
3851                    self.resolver.stream_data_from_obj(obj)?
3852                }
3853            }
3854            #[cfg(not(feature = "jpx"))]
3855            {
3856                self.resolver.stream_data_from_obj(obj)?
3857            }
3858        } else {
3859            self.resolver.stream_data_from_obj(obj)?
3860        };
3861
3862        // For DCTDecode, the JPEG's actual dimensions may differ from the PDF
3863        // dict's /Width and /Height.  Trust the image header when they disagree,
3864        // but only when the JPEG dimensions are close to the PDF dict values.
3865        let (width, height) = if filter_is_dct {
3866            if let Some(raw) = self.resolver.raw_stream_bytes(obj)
3867                && let Some((jw, jh)) = crate::filters::jpeg_dimensions(raw)
3868                && (jw != width || jh != height)
3869                && jw <= width * 2
3870                && jh <= height * 2
3871            {
3872                (jw, jh)
3873            } else {
3874                (width, height)
3875            }
3876        } else if filter_is_jpx {
3877            #[cfg(feature = "jpx")]
3878            {
3879                // JPXDecode: the JPEG 2000 stream may have different dimensions
3880                // than the PDF dict (malformed but common). Decode the filter chain
3881                // up to but not including JPXDecode to get the raw JP2 data.
3882                if let Some(raw) = self.resolver.raw_stream_bytes(obj) {
3883                    // Apply preceding filters (e.g. ASCIIHexDecode) to get JP2 data
3884                    let jp2_data = crate::filters::decode_pre_jpx(raw, dict);
3885                    if let Some((jw, jh)) = crate::filters::jpx_dimensions(&jp2_data)
3886                        && (jw != width || jh != height)
3887                    {
3888                        (jw, jh)
3889                    } else {
3890                        (width, height)
3891                    }
3892                } else {
3893                    (width, height)
3894                }
3895            }
3896            #[cfg(not(feature = "jpx"))]
3897            {
3898                (width, height)
3899            }
3900        } else {
3901            (width, height)
3902        };
3903
3904        // For JPXDecode, the JP2 decoder returns all components present in the
3905        // codestream, which may include alpha or other channels beyond what the
3906        // PDF's explicit ColorSpace expects. With SMaskInData >= 1 the extra
3907        // alpha component is exposed as a soft mask; with SMaskInData == 0
3908        // (default), per ISO 32000-2 §13.5.7.2 the encoded soft-mask information
3909        // shall be ignored — drop the extras so the gray/RGB/CMYK pipeline
3910        // doesn't reinterpret interleaved alpha bytes as image samples.
3911        // When no explicit ColorSpace is present, infer it from the decoded
3912        // data length.
3913        let (resolved_cs, sample_data, smask_in_data_alpha) =
3914            if !is_image_mask && filter_is_jpx && has_explicit_cs {
3915                let n_cs = resolved_cs
3916                    .as_ref()
3917                    .map_or(3, |cs| cs.num_components() as usize);
3918                let pixels = width as usize * height as usize;
3919                let decoded_comps = if pixels > 0 {
3920                    sample_data.len() / pixels
3921                } else {
3922                    n_cs
3923                };
3924                if smask_in_data >= 1 && decoded_comps == n_cs + 1 {
3925                    // Extract the alpha channel (last component per pixel)
3926                    let mut color_data = Vec::with_capacity(pixels.saturating_mul(n_cs));
3927                    let mut alpha_data = Vec::with_capacity(pixels);
3928                    for chunk in sample_data.chunks_exact(decoded_comps) {
3929                        color_data.extend_from_slice(&chunk[..n_cs]);
3930                        alpha_data.push(chunk[n_cs]);
3931                    }
3932                    (resolved_cs, color_data, Some(alpha_data))
3933                } else if decoded_comps > n_cs {
3934                    // Drop extra components (e.g. ignored alpha) — keep only the
3935                    // first n_cs samples of each pixel.
3936                    let mut color_data = Vec::with_capacity(pixels.saturating_mul(n_cs));
3937                    for chunk in sample_data.chunks_exact(decoded_comps) {
3938                        color_data.extend_from_slice(&chunk[..n_cs]);
3939                    }
3940                    (resolved_cs, color_data, None)
3941                } else {
3942                    // Component count matches (or is unexpectedly low) — pass through
3943                    (resolved_cs, sample_data, None)
3944                }
3945            } else if !is_image_mask && !has_explicit_cs {
3946                let pixels = width as usize * height as usize;
3947                if pixels > 0 {
3948                    let n_comps = sample_data.len() / pixels;
3949                    // For 4-component JPX images, check JP2 metadata to distinguish
3950                    // RGBA (sRGB + alpha) from CMYK. Without this, RGBA images get
3951                    // misidentified as CMYK, producing wrong colors (e.g., orange → blue).
3952                    if n_comps == 4 && self.is_jpx_rgba(obj) {
3953                        if smask_in_data >= 1 {
3954                            // SMaskInData: the JP2 alpha channel is the soft mask.
3955                            // Premultiply alpha (tiny-skia expects premultiplied RGBA).
3956                            let mut rgba = sample_data;
3957                            for chunk in rgba.chunks_exact_mut(4) {
3958                                let a = chunk[3] as u16;
3959                                if a == 0 {
3960                                    chunk[0] = 0;
3961                                    chunk[1] = 0;
3962                                    chunk[2] = 0;
3963                                } else if a < 255 {
3964                                    chunk[0] = ((chunk[0] as u16 * a + 127) / 255) as u8;
3965                                    chunk[1] = ((chunk[1] as u16 * a + 127) / 255) as u8;
3966                                    chunk[2] = ((chunk[2] as u16 * a + 127) / 255) as u8;
3967                                }
3968                            }
3969                            (None, rgba, None)
3970                        } else {
3971                            // No embedded mask — strip alpha from RGBA → RGB.
3972                            let mut rgb = Vec::with_capacity(pixels * 3);
3973                            for chunk in sample_data.chunks_exact(4) {
3974                                rgb.push(chunk[0]);
3975                                rgb.push(chunk[1]);
3976                                rgb.push(chunk[2]);
3977                            }
3978                            (Some(ResolvedColorSpace::DeviceRGB), rgb, None)
3979                        }
3980                    } else {
3981                        let cs = match n_comps {
3982                            1 => ResolvedColorSpace::DeviceGray,
3983                            4 => ResolvedColorSpace::DeviceCMYK,
3984                            _ => ResolvedColorSpace::DeviceRGB,
3985                        };
3986                        (Some(cs), sample_data, None)
3987                    }
3988                } else {
3989                    (resolved_cs, sample_data, None)
3990                }
3991            } else {
3992                (resolved_cs, sample_data, None)
3993            };
3994
3995        // Image matrix: [width 0 0 -height 0 height] maps unit square to image
3996        let image_matrix =
3997            Matrix::new(width as f64, 0.0, 0.0, -(height as f64), 0.0, height as f64);
3998
3999        // Imagemask with shading pattern fill: use SoftMasked to clip shading to mask shape
4000        if is_image_mask && self.gstate.fill_shading_pattern.is_some() {
4001            let shading_box = self.gstate.fill_shading_pattern.clone().unwrap();
4002            let row_bytes = width.div_ceil(8);
4003            let mut gray = vec![0u8; (width * height) as usize];
4004            for y in 0..height {
4005                for x in 0..width {
4006                    let byte_idx = (y * row_bytes + x / 8) as usize;
4007                    let bit_idx = 7 - (x % 8);
4008                    let bit = if byte_idx < sample_data.len() {
4009                        (sample_data[byte_idx] >> bit_idx) & 1
4010                    } else {
4011                        0
4012                    };
4013                    let painted = if polarity { bit == 1 } else { bit == 0 };
4014                    gray[(y * width + x) as usize] = if painted { 255 } else { 0 };
4015                }
4016            }
4017
4018            let mut mask_dl = DisplayList::new();
4019            mask_dl.push(DisplayElement::Image {
4020                sample_data: Arc::new(gray),
4021                params: ImageParams {
4022                    width,
4023                    height,
4024                    color_space: ImageColorSpace::DeviceGray,
4025                    bits_per_component: 8,
4026                    ctm: self.gstate.ctm,
4027                    image_matrix,
4028                    interpolate: false,
4029                    mask_color: None,
4030                    alpha: 1.0,
4031                    blend_mode: 0,
4032                    overprint: false,
4033                    overprint_mode: 0,
4034                    opm_paired: false,
4035                    painted_channels: 0,
4036                    alpha_is_shape: false,
4037                    rendering_intent: 0,
4038                },
4039            });
4040
4041            let mut content_dl = DisplayList::new();
4042            for elem in shading_box.0.elements() {
4043                content_dl.push(elem.clone());
4044            }
4045
4046            let corners = [
4047                self.gstate.ctm.transform_point(0.0, 0.0),
4048                self.gstate.ctm.transform_point(width as f64, 0.0),
4049                self.gstate.ctm.transform_point(0.0, height as f64),
4050                self.gstate.ctm.transform_point(width as f64, height as f64),
4051            ];
4052            let x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
4053            let y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
4054            let x_max = corners
4055                .iter()
4056                .map(|c| c.0)
4057                .fold(f64::NEG_INFINITY, f64::max);
4058            let y_max = corners
4059                .iter()
4060                .map(|c| c.1)
4061                .fold(f64::NEG_INFINITY, f64::max);
4062
4063            let parent_clip_bbox = self.current_clip_bbox();
4064            self.display_list.push(DisplayElement::SoftMasked {
4065                mask: mask_dl,
4066                content: content_dl,
4067                params: SoftMaskParams {
4068                    subtype: SoftMaskSubtype::Luminosity,
4069                    bbox: [x_min, y_min, x_max, y_max],
4070                    backdrop_color: None,
4071                    transfer_invert: false,
4072                    has_nested_mask_scope: false,
4073                    parent_clip_bbox,
4074                },
4075                mask_cache: Arc::new(Mutex::new(None)),
4076            });
4077            return Ok(());
4078        }
4079
4080        // OPM 1 + overprint on + CMYK all-zero fill + no pattern: "no ink" means
4081        // don't paint.  Skip the ImageMask so underlying content shows through.
4082        // When a pattern IS active, the pattern provides the real color and
4083        // the ImageMask acts as a text stencil — it must still render.
4084        // NOTE: OPM only takes effect when overprint (OP/op) is enabled.
4085        if is_image_mask
4086            && self.gstate.overprint
4087            && self.gstate.overprint_mode == 1
4088            && self.gstate.fill_pattern.is_none()
4089            && self.gstate.fill_shading_pattern.is_none()
4090            && self.gstate.fill_color.native_cmyk == Some((0.0, 0.0, 0.0, 0.0))
4091        {
4092            return Ok(());
4093        }
4094
4095        // Imagemask with tiling pattern fill: use SoftMasked to clip pattern to mask shape.
4096        // The ImageMask provides the text stencil and the pattern provides the color.
4097        // We emit tile images directly (composing their CTM with the pattern matrix)
4098        // rather than using PatternFill, because the pattern tile covers the full page
4099        // and each strip has unique image data — the tiling renderer can't handle this
4100        // efficiently.
4101        if is_image_mask && self.gstate.fill_pattern.is_some() {
4102            let pattern = self.gstate.fill_pattern.clone().unwrap();
4103            let row_bytes = width.div_ceil(8);
4104            let mut gray = vec![0u8; (width * height) as usize];
4105            for y in 0..height {
4106                for x in 0..width {
4107                    let byte_idx = (y * row_bytes + x / 8) as usize;
4108                    let bit_idx = 7 - (x % 8);
4109                    let bit = if byte_idx < sample_data.len() {
4110                        (sample_data[byte_idx] >> bit_idx) & 1
4111                    } else {
4112                        0
4113                    };
4114                    let painted = if polarity { bit == 1 } else { bit == 0 };
4115                    gray[(y * width + x) as usize] = if painted { 255 } else { 0 };
4116                }
4117            }
4118
4119            let mut mask_dl = DisplayList::new();
4120            mask_dl.push(DisplayElement::Image {
4121                sample_data: Arc::new(gray),
4122                params: ImageParams {
4123                    width,
4124                    height,
4125                    color_space: ImageColorSpace::DeviceGray,
4126                    bits_per_component: 8,
4127                    ctm: self.gstate.ctm,
4128                    image_matrix,
4129                    interpolate: false,
4130                    mask_color: None,
4131                    alpha: 1.0,
4132                    blend_mode: 0,
4133                    overprint: false,
4134                    overprint_mode: 0,
4135                    opm_paired: false,
4136                    painted_channels: 0,
4137                    alpha_is_shape: false,
4138                    rendering_intent: 0,
4139                },
4140            });
4141
4142            let corners = [
4143                self.gstate.ctm.transform_point(0.0, 0.0),
4144                self.gstate.ctm.transform_point(width as f64, 0.0),
4145                self.gstate.ctm.transform_point(0.0, height as f64),
4146                self.gstate.ctm.transform_point(width as f64, height as f64),
4147            ];
4148            let x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
4149            let y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
4150            let x_max = corners
4151                .iter()
4152                .map(|c| c.0)
4153                .fold(f64::NEG_INFINITY, f64::max);
4154            let y_max = corners
4155                .iter()
4156                .map(|c| c.1)
4157                .fold(f64::NEG_INFINITY, f64::max);
4158
4159            // Emit tile images directly as content, composing their CTM with
4160            // the pattern matrix to place them in device space.
4161            let pm = &pattern.pattern_matrix;
4162            let mut content_dl = DisplayList::new();
4163            for elem in pattern.tile.elements() {
4164                if let DisplayElement::Image {
4165                    sample_data: sd,
4166                    params: ip,
4167                } = elem
4168                {
4169                    let dev_ctm = pm.multiply(&ip.ctm);
4170                    content_dl.push(DisplayElement::Image {
4171                        sample_data: sd.clone(),
4172                        params: ImageParams {
4173                            ctm: dev_ctm,
4174                            ..ip.clone()
4175                        },
4176                    });
4177                }
4178            }
4179
4180            let parent_clip_bbox = self.current_clip_bbox();
4181            self.display_list.push(DisplayElement::SoftMasked {
4182                mask: mask_dl,
4183                content: content_dl,
4184                params: SoftMaskParams {
4185                    subtype: SoftMaskSubtype::Luminosity,
4186                    bbox: [x_min, y_min, x_max, y_max],
4187                    backdrop_color: None,
4188                    transfer_invert: false,
4189                    has_nested_mask_scope: false,
4190                    parent_clip_bbox,
4191                },
4192                mask_cache: Arc::new(Mutex::new(None)),
4193            });
4194            return Ok(());
4195        }
4196
4197        // For multi-input DeviceN images, evaluate the tinting function directly
4198        // per pixel to avoid lossy N-D lookup table interpolation.  A pre-sampled
4199        // table with spd^N entries can never faithfully represent all 256^N possible
4200        // 8-bit input combinations for N≥2.  Direct evaluation is exact and fast
4201        // enough for typical image sizes.
4202        let (color_space, sample_data) = if !is_image_mask
4203            && let Some(ResolvedColorSpace::DeviceN {
4204                names,
4205                alt,
4206                tint_fn: Some(func),
4207            }) = resolved_cs.as_ref()
4208            && names.len() >= 2
4209            && matches!(
4210                alt.as_ref(),
4211                ResolvedColorSpace::DeviceGray | ResolvedColorSpace::DeviceRGB
4212            ) {
4213            let ni = names.len();
4214            let npixels = width as usize * height as usize;
4215            let mut rgba = vec![255u8; npixels * 4];
4216            let mut inputs = vec![0.0f64; ni];
4217            for i in 0..npixels {
4218                let si = i * ni;
4219                for (c, inp) in inputs.iter_mut().enumerate() {
4220                    *inp = sample_data.get(si + c).copied().unwrap_or(0) as f64 / 255.0;
4221                }
4222                let out = func.evaluate(&inputs);
4223                let (r, g, b) = color_space::alt_comps_to_rgb_f64(&out, alt);
4224                let pi = i * 4;
4225                rgba[pi] = r;
4226                rgba[pi + 1] = g;
4227                rgba[pi + 2] = b;
4228            }
4229            (ImageColorSpace::PreconvertedRGBA, rgba)
4230        } else if is_image_mask {
4231            (
4232                ImageColorSpace::Mask {
4233                    color: self.gstate.fill_color.clone(),
4234                    polarity,
4235                    spot_color: self.gstate.fill_spot_color.clone(),
4236                },
4237                sample_data,
4238            )
4239        } else if let Some(ref rcs) = resolved_cs {
4240            (to_image_color_space(rcs), sample_data)
4241        } else {
4242            // resolved_cs is None for JPX RGBA with SMaskInData — already RGBA
4243            (ImageColorSpace::PreconvertedRGBA, sample_data)
4244        };
4245
4246        // JPXDecode with internal palette (pclr): hayro-jpeg2000 applies the JP2 palette
4247        // and returns expanded data (e.g. 3-component RGB for a 1-component codestream).
4248        // Per PDF spec 7.4.9, the JP2 palette is applied before the PDF color space.
4249        // When the PDF says Indexed but the JP2 already expanded the palette,
4250        // switch to the base color space since the data is already depalettized.
4251        let color_space = if !is_image_mask {
4252            if let ImageColorSpace::Indexed { base, .. } = &color_space {
4253                let expected_1comp = (width * height) as usize;
4254                let base_n = base.num_components() as usize;
4255                if sample_data.len() == expected_1comp * base_n && base_n > 1 {
4256                    *base.clone()
4257                } else {
4258                    color_space
4259                }
4260            } else {
4261                color_space
4262            }
4263        } else {
4264            color_space
4265        };
4266
4267        let interpolate = dict
4268            .get(b"Interpolate")
4269            .and_then(|o| match o {
4270                PdfObj::Bool(b) => Some(*b),
4271                _ => None,
4272            })
4273            .unwrap_or(false);
4274
4275        // Mask color (for color-key masking) or explicit stencil mask (stream ref)
4276        let (mask_color, explicit_mask_data) = match dict.get(b"Mask") {
4277            Some(PdfObj::Array(arr)) => {
4278                // Color-key mask: array of component ranges
4279                let mc: Vec<u8> = arr
4280                    .iter()
4281                    .filter_map(|o| o.as_int().map(|n| n as u8))
4282                    .collect();
4283                (Some(mc), None)
4284            }
4285            Some(_mask_obj) => {
4286                // Explicit stencil mask: indirect reference to 1-bit ImageMask stream
4287                let mask_alpha = self
4288                    .resolve_explicit_mask(dict, width, height)
4289                    .unwrap_or(None);
4290                (None, mask_alpha)
4291            }
4292            None => (None, None),
4293        };
4294
4295        // Convert data if BPC != 8 (but NOT for image masks — keep raw 1-bit data).
4296        // JPXDecode (JPEG 2000) and DCTDecode (JPEG) always produce 8-bit output
4297        // regardless of the /BitsPerComponent value in the PDF dict.
4298        let is_jpx = filter_is_jpx;
4299        let is_dct = filter_is_dct;
4300        let is_indexed = matches!(&color_space, ImageColorSpace::Indexed { .. });
4301        // Expand sub-byte samples (1/2/4 BPC) to 8-bit since they're packed
4302        // with geometry-dependent alignment. Downsample 16-bit to 8-bit (take
4303        // high byte) — downstream ICC and color conversion assumes 8-bit data.
4304        let (sample_data, display_bpc) =
4305            if is_image_mask || bpc == 8 || bpc == 0 || is_jpx || is_dct {
4306                (sample_data, if is_dct || is_jpx { 8 } else { bpc })
4307            } else if bpc == 16 {
4308                // Take high byte of each 16-bit big-endian sample
4309                (sample_data.chunks(2).map(|c| c[0]).collect(), 8)
4310            } else if bpc > 8 {
4311                (sample_data, bpc)
4312            } else {
4313                (
4314                    expand_bits_to_bytes(
4315                        &sample_data,
4316                        bpc,
4317                        width,
4318                        height,
4319                        color_space.num_components(),
4320                        is_indexed,
4321                    ),
4322                    8,
4323                )
4324            };
4325
4326        // Apply /Decode array if present (maps sample values to color component values).
4327        // Default for most color spaces is [0 1 0 1 ...] (identity).
4328        // For Indexed color spaces, default is [0 2^bpc-1] and values are indices.
4329        // CMYK images may use [1 0 1 0 1 0 1 0] to invert values.
4330        let sample_data = if !is_image_mask {
4331            if let Some(decode) = dict.get_array(b"Decode") {
4332                let n_comps = color_space.num_components() as usize;
4333                let decode_vals: Vec<f64> = decode.iter().filter_map(|o| o.as_f64()).collect();
4334                if decode_vals.len() >= n_comps * 2 {
4335                    let effective_bpc = if is_jpx || is_dct { 8 } else { bpc };
4336                    let max_sample = ((1u32 << effective_bpc) - 1) as f64;
4337                    // Check if it's the default Decode for this color space.
4338                    // Indexed: default is [0 max_sample]; others: [0 1 0 1 ...].
4339                    let is_default = if is_indexed {
4340                        decode_vals.len() == 2
4341                            && (decode_vals[0]).abs() < 1e-6
4342                            && (decode_vals[1] - max_sample).abs() < 1e-6
4343                    } else {
4344                        decode_vals.chunks(2).all(|pair| {
4345                            pair.len() == 2
4346                                && (pair[0] - 0.0).abs() < 1e-6
4347                                && (pair[1] - 1.0).abs() < 1e-6
4348                        })
4349                    };
4350                    if !is_default {
4351                        // After expand_bits_to_bytes: indexed data keeps raw values
4352                        // (0 to 2^bpc-1), non-indexed data is scaled to 0-255.
4353                        let max_val = if is_indexed {
4354                            ((1u32 << effective_bpc) - 1) as f64
4355                        } else {
4356                            255.0f64
4357                        };
4358                        let mut result = Vec::with_capacity(sample_data.len());
4359                        if is_indexed {
4360                            // Indexed: Decode maps sample values to index values (integer range)
4361                            let d_min = decode_vals[0];
4362                            let d_max = decode_vals[1];
4363                            for &sample in sample_data.iter() {
4364                                let val = d_min + (sample as f64 / max_val) * (d_max - d_min);
4365                                result.push(val.round().clamp(0.0, 255.0) as u8);
4366                            }
4367                        } else {
4368                            // Non-indexed: Decode maps to normalized [0,1] component values
4369                            for (i, &sample) in sample_data.iter().enumerate() {
4370                                let comp = i % n_comps;
4371                                let d_min = decode_vals[comp * 2];
4372                                let d_max = decode_vals[comp * 2 + 1];
4373                                let val = d_min + (sample as f64 / max_val) * (d_max - d_min);
4374                                result.push((val.clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4375                            }
4376                        }
4377                        result
4378                    } else {
4379                        sample_data
4380                    }
4381                } else {
4382                    sample_data
4383                }
4384            } else {
4385                sample_data
4386            }
4387        } else {
4388            sample_data
4389        };
4390
4391        // In CMYK page groups, route DeviceGray-derived images (DeviceGray
4392        // itself, Separation with gray alt, DeviceN with gray alt) through
4393        // the K plate so they composite equivalently to DeviceCMYK 0/0/0/(1−g).
4394        // Required by GWG 17.3 (JBIG2 compression) and GWG 23.0's "4 different
4395        // Grays" test.
4396        let (sample_data, color_space, resolved_cs) = if !is_image_mask {
4397            let was_device_gray = matches!(color_space, ImageColorSpace::DeviceGray);
4398            let (new_cs, new_data) =
4399                self.cmyk_group_promote_image(color_space, sample_data, width, height);
4400            let new_resolved = if was_device_gray && matches!(new_cs, ImageColorSpace::DeviceCMYK) {
4401                Some(ResolvedColorSpace::DeviceCMYK)
4402            } else {
4403                resolved_cs
4404            };
4405            (new_data, new_cs, new_resolved)
4406        } else {
4407            (sample_data, color_space, resolved_cs)
4408        };
4409
4410        // Register the ICC profile with the cache so the rasterizer can find
4411        // it by hash.  Conversion itself is deferred to samples_to_rgba() so
4412        // only pixels that actually land on screen pay the color-transform cost.
4413        if !is_image_mask {
4414            if let Some(ref rcs) = resolved_cs {
4415                register_icc_profile(rcs, &mut self.icc_cache);
4416            }
4417        }
4418
4419        // Handle SMask (soft mask / alpha channel).
4420        // Emit the image and its SMask as a SoftMasked display element so the
4421        // renderer scales them independently, preserving visible edges at hard
4422        // alpha boundaries (e.g. text outlines on transparent backgrounds).
4423        // When the mask is larger than the image (e.g., 1-bit text mask on a 2×2
4424        // color image), upscale the image to the mask dimensions to preserve detail.
4425        let smask_result = if !is_image_mask {
4426            let dict_smask = self.resolve_smask(dict, width, height)?;
4427            // Use SMaskInData alpha when no explicit /SMask entry exists
4428            if dict_smask.is_none() {
4429                if let Some(alpha) = smask_in_data_alpha {
4430                    Some((alpha, width, height, None))
4431                } else {
4432                    None
4433                }
4434            } else {
4435                dict_smask
4436            }
4437        } else {
4438            None
4439        };
4440
4441        // Handle explicit stencil mask (/Mask pointing to 1-bit ImageMask stream).
4442        // When the mask is larger than the image (MRC scanned PDFs), upscale the
4443        // image to the mask dimensions so the high-res edge detail is preserved.
4444        let (sample_data, color_space, width, height) =
4445            if let Some((mask_alpha, mw, mh)) = explicit_mask_data {
4446                // Expand Indexed data to the base color space before upscaling,
4447                // so bilinear interpolation blends actual colors, not indices.
4448                let (up_data, up_cs) = if let ImageColorSpace::Indexed {
4449                    base,
4450                    hival,
4451                    lookup,
4452                } = &color_space
4453                {
4454                    let n_base = base.num_components() as usize;
4455                    let n_pixels = (width * height) as usize;
4456                    // `n_pixels` can be up to MAX_IMAGE_PIXELS, so multiplying
4457                    // by the component count leaves the u32 range and, on a
4458                    // 32-bit `usize` target, the usize range too.
4459                    let mut expanded = vec![0u8; n_pixels.saturating_mul(n_base)];
4460                    for i in 0..n_pixels {
4461                        let idx = sample_data.get(i).copied().unwrap_or(0) as usize;
4462                        let idx = idx.min(*hival as usize);
4463                        let offset = idx * n_base;
4464                        for c in 0..n_base {
4465                            expanded[i * n_base + c] = lookup.get(offset + c).copied().unwrap_or(0);
4466                        }
4467                    }
4468                    (expanded, *base.clone())
4469                } else {
4470                    (sample_data, color_space)
4471                };
4472                let (img_data, img_w, img_h) = if mw > width || mh > height {
4473                    // Upscale image to mask dimensions using bilinear interpolation
4474                    let upscaled = bilinear_upsample_image(&up_data, width, height, mw, mh, &up_cs);
4475                    (upscaled, mw, mh)
4476                } else {
4477                    (up_data, width, height)
4478                };
4479                let rgba = merge_rgb_with_smask(
4480                    &img_data,
4481                    &mask_alpha,
4482                    &up_cs,
4483                    img_w,
4484                    img_h,
4485                    Some(&self.icc_cache),
4486                );
4487                (rgba, ImageColorSpace::PreconvertedRGBA, img_w, img_h)
4488            } else {
4489                (sample_data, color_space, width, height)
4490            };
4491
4492        // Apply transfer functions to image pixel data (colorizes grayscale charts etc.)
4493        let sample_data = if !is_image_mask && self.gstate.transfer.has_functions() {
4494            let n_comps = color_space.num_components() as usize;
4495            if n_comps >= 3 {
4496                let mut data = sample_data;
4497                apply_transfer_to_image(&mut data, &self.gstate.transfer, n_comps);
4498                data
4499            } else {
4500                sample_data
4501            }
4502        } else {
4503            sample_data
4504        };
4505
4506        // Recompute image_matrix if dimensions changed (e.g. upscaled to match mask)
4507        let image_matrix =
4508            Matrix::new(width as f64, 0.0, 0.0, -(height as f64), 0.0, height as f64);
4509
4510        // For K-only Indexed/DeviceCMYK palettes (grayscale images encoded as
4511        // CMYK), narrow painted_channels to CMYK_K so the overprint renderer
4512        // only paints the K channel.  This preserves spot-color contributions
4513        // on C/M/Y underneath — required by GWG 3.1 (Gray Image Overprint).
4514        // Non-K palettes keep CMYK_ALL so all channels are painted, matching
4515        // the PDF spec rule that OPM 1 per-channel zeroing does not apply to
4516        // Indexed color spaces (required by GWG 1.0 h/i).
4517        let painted_channels_override = if let ImageColorSpace::Indexed {
4518            base,
4519            hival,
4520            lookup,
4521        } = &color_space
4522        {
4523            if matches!(
4524                base.as_ref(),
4525                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
4526            ) {
4527                let n_entries = (*hival as usize + 1).min(lookup.len() / 4);
4528                let is_k_only = n_entries > 0
4529                    && (0..n_entries).all(|i| {
4530                        let off = i * 4;
4531                        lookup.get(off).copied().unwrap_or(0) == 0
4532                            && lookup.get(off + 1).copied().unwrap_or(0) == 0
4533                            && lookup.get(off + 2).copied().unwrap_or(0) == 0
4534                    });
4535                if is_k_only {
4536                    stet_graphics::device::CMYK_K
4537                } else {
4538                    stet_graphics::device::CMYK_ALL
4539                }
4540            } else {
4541                resolved_cs
4542                    .as_ref()
4543                    .map(painted_channels_for_cs)
4544                    .unwrap_or(self.gstate.fill_painted_channels)
4545            }
4546        } else {
4547            resolved_cs
4548                .as_ref()
4549                .map(painted_channels_for_cs)
4550                .unwrap_or(self.gstate.fill_painted_channels)
4551        };
4552
4553        let image_params = ImageParams {
4554            width,
4555            height,
4556            color_space,
4557            bits_per_component: display_bpc as u8,
4558            ctm: self.gstate.ctm,
4559            image_matrix,
4560            interpolate,
4561            mask_color,
4562            alpha: self.gstate.fill_alpha,
4563            blend_mode: self.gstate.blend_mode,
4564            overprint: self.gstate.overprint,
4565            overprint_mode: self.gstate.overprint_mode,
4566            opm_paired: self.gstate.opm_paired,
4567            painted_channels: painted_channels_override,
4568            alpha_is_shape: self.gstate.alpha_is_shape,
4569            rendering_intent: image_intent,
4570        };
4571
4572        // When an SMask is present, emit as SoftMasked so the renderer scales
4573        // image and mask independently, preserving edge detail at hard alpha
4574        // boundaries that premultiplied-alpha averaging would make invisible.
4575        if let Some((smask_data, mw, mh, matte)) = smask_result {
4576            // Upscale image to mask dimensions if the mask is larger — this
4577            // preserves sharp mask edges (e.g. MRC text masks on low-res images).
4578            // Cap the target pixel count to avoid allocating enormous buffers
4579            // when the mask is vastly larger than the image (e.g. 34862×4332
4580            // mask on a 2×2 image in issue16263.pdf). The limit is generous
4581            // enough for high-DPI and zoomed rendering but prevents pathological
4582            // cases from consuming gigabytes of memory.
4583            const MAX_PIXELS: u64 = 16_000_000; // ~4096×4096
4584            let mut target_w = mw.max(width);
4585            let mut target_h = mh.max(height);
4586            if (target_w as u64) * (target_h as u64) > MAX_PIXELS {
4587                let scale = (MAX_PIXELS as f64 / (target_w as f64 * target_h as f64)).sqrt();
4588                target_w = (target_w as f64 * scale).ceil() as u32;
4589                target_h = (target_h as f64 * scale).ceil() as u32;
4590            }
4591            let (sample_data, width, height) = if target_w > width || target_h > height {
4592                let upscaled = bilinear_upsample_image(
4593                    &sample_data,
4594                    width,
4595                    height,
4596                    target_w,
4597                    target_h,
4598                    &image_params.color_space,
4599                );
4600                (upscaled, target_w, target_h)
4601            } else {
4602                (sample_data, width, height)
4603            };
4604
4605            // Resample SMask to image dimensions if they differ
4606            let smask_data = if mw != width || mh != height {
4607                let mut resampled = vec![0u8; (width * height) as usize];
4608                for y in 0..height {
4609                    let sy = (y as u64 * mh as u64 / height as u64) as u32;
4610                    for x in 0..width {
4611                        let sx = (x as u64 * mw as u64 / width as u64) as u32;
4612                        resampled[(y * width + x) as usize] = smask_data
4613                            .get((sy * mw + sx) as usize)
4614                            .copied()
4615                            .unwrap_or(0);
4616                    }
4617                }
4618                resampled
4619            } else {
4620                smask_data
4621            };
4622
4623            // Un-premultiply image colors when Matte is specified (PDF spec 11.6.5.3).
4624            // The image data was pre-composited against the Matte color; reverse this
4625            // to recover the original colors before alpha compositing.
4626            let sample_data = if let Some(ref mc) = matte {
4627                let n_comps = image_params.color_space.num_components() as usize;
4628                if mc.len() >= n_comps && n_comps >= 3 {
4629                    let mut out = sample_data;
4630                    let pixels = (width * height) as usize;
4631                    for i in 0..pixels {
4632                        let a = smask_data[i] as f64 / 255.0;
4633                        if a > 0.0 && a < 1.0 {
4634                            for c in 0..n_comps.min(3) {
4635                                let m = (mc[c] * 255.0).clamp(0.0, 255.0);
4636                                let premul = out[i * n_comps + c] as f64;
4637                                let orig = m + (premul - m) / a;
4638                                out[i * n_comps + c] = orig.round().clamp(0.0, 255.0) as u8;
4639                            }
4640                        }
4641                    }
4642                    out
4643                } else {
4644                    sample_data
4645                }
4646            } else {
4647                sample_data
4648            };
4649
4650            // Cache the fully-processed image data for reuse (Arc for cheap cloning).
4651            let sample_arc = Arc::new(sample_data);
4652            let smask_arc = Arc::new(smask_data);
4653            if let PdfObj::Ref(obj_num, _) = obj {
4654                self.image_cache.insert(
4655                    *obj_num,
4656                    CachedImage {
4657                        sample_data: Arc::clone(&sample_arc),
4658                        width,
4659                        height,
4660                        color_space: image_params.color_space.clone(),
4661                        bits_per_component: image_params.bits_per_component,
4662                        interpolate,
4663                        mask_color: image_params.mask_color.clone(),
4664                        painted_channels: image_params.painted_channels,
4665                        smask: Some((Arc::clone(&smask_arc), width, height, matte.clone())),
4666                        rendering_intent: image_params.rendering_intent,
4667                    },
4668                );
4669            }
4670
4671            let image_matrix =
4672                Matrix::new(width as f64, 0.0, 0.0, -(height as f64), 0.0, height as f64);
4673
4674            let mut mask_dl = DisplayList::new();
4675            mask_dl.push(DisplayElement::Image {
4676                sample_data: smask_arc,
4677                params: ImageParams {
4678                    width,
4679                    height,
4680                    color_space: ImageColorSpace::DeviceGray,
4681                    bits_per_component: 8,
4682                    ctm: self.gstate.ctm,
4683                    image_matrix,
4684                    interpolate,
4685                    mask_color: None,
4686                    alpha: 1.0,
4687                    blend_mode: 0,
4688                    overprint: false,
4689                    overprint_mode: 0,
4690                    opm_paired: false,
4691                    painted_channels: 0,
4692                    alpha_is_shape: false,
4693                    rendering_intent: 0,
4694                },
4695            });
4696
4697            let mut content_dl = DisplayList::new();
4698            content_dl.push(DisplayElement::Image {
4699                sample_data: sample_arc,
4700                params: ImageParams {
4701                    width,
4702                    height,
4703                    image_matrix,
4704                    ..image_params
4705                },
4706            });
4707
4708            let corners = [
4709                self.gstate.ctm.transform_point(0.0, 0.0),
4710                self.gstate.ctm.transform_point(1.0, 0.0),
4711                self.gstate.ctm.transform_point(0.0, 1.0),
4712                self.gstate.ctm.transform_point(1.0, 1.0),
4713            ];
4714            let x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
4715            let y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
4716            let x_max = corners
4717                .iter()
4718                .map(|c| c.0)
4719                .fold(f64::NEG_INFINITY, f64::max);
4720            let y_max = corners
4721                .iter()
4722                .map(|c| c.1)
4723                .fold(f64::NEG_INFINITY, f64::max);
4724
4725            let parent_clip_bbox = self.current_clip_bbox();
4726            self.display_list.push(DisplayElement::SoftMasked {
4727                mask: mask_dl,
4728                content: content_dl,
4729                params: SoftMaskParams {
4730                    subtype: SoftMaskSubtype::Luminosity,
4731                    bbox: [x_min, y_min, x_max, y_max],
4732                    backdrop_color: None,
4733                    transfer_invert: false,
4734                    has_nested_mask_scope: false,
4735                    parent_clip_bbox,
4736                },
4737                mask_cache: Arc::new(Mutex::new(None)),
4738            });
4739        } else {
4740            // Cache plain image for reuse (Arc for cheap cloning).
4741            let sample_arc = Arc::new(sample_data);
4742            if let PdfObj::Ref(obj_num, _) = obj {
4743                self.image_cache.insert(
4744                    *obj_num,
4745                    CachedImage {
4746                        sample_data: Arc::clone(&sample_arc),
4747                        width,
4748                        height,
4749                        color_space: image_params.color_space.clone(),
4750                        bits_per_component: image_params.bits_per_component,
4751                        interpolate,
4752                        mask_color: image_params.mask_color.clone(),
4753                        painted_channels: image_params.painted_channels,
4754                        smask: None,
4755                        rendering_intent: image_params.rendering_intent,
4756                    },
4757                );
4758            }
4759
4760            self.display_list.push(DisplayElement::Image {
4761                sample_data: sample_arc,
4762                params: image_params,
4763            });
4764        }
4765        Ok(())
4766    }
4767
4768    /// Build a CachedImage, pre-downscaling if the image is much larger than
4769    /// its device-pixel target size (detected from the current CTM).
4770    #[allow(clippy::too_many_arguments)]
4771    /// Emit a display element from a cached image, applying current graphics state.
4772    /// The cache already contains pre-downscaled data when applicable.
4773    fn emit_cached_image(&mut self, cached: CachedImage) -> Result<(), PdfError> {
4774        let (sample_data, smask, width, height) = (
4775            cached.sample_data,
4776            cached.smask,
4777            cached.width,
4778            cached.height,
4779        );
4780
4781        let image_matrix =
4782            Matrix::new(width as f64, 0.0, 0.0, -(height as f64), 0.0, height as f64);
4783        let image_params = ImageParams {
4784            width,
4785            height,
4786            color_space: cached.color_space,
4787            bits_per_component: cached.bits_per_component,
4788            ctm: self.gstate.ctm,
4789            image_matrix,
4790            interpolate: cached.interpolate,
4791            mask_color: cached.mask_color,
4792            alpha: self.gstate.fill_alpha,
4793            blend_mode: self.gstate.blend_mode,
4794            overprint: self.gstate.overprint,
4795            overprint_mode: self.gstate.overprint_mode,
4796            opm_paired: self.gstate.opm_paired,
4797            painted_channels: cached.painted_channels,
4798            alpha_is_shape: self.gstate.alpha_is_shape,
4799            rendering_intent: cached.rendering_intent,
4800        };
4801
4802        if let Some((smask_data, sw, sh, _matte)) = smask {
4803            let mut mask_dl = DisplayList::new();
4804            mask_dl.push(DisplayElement::Image {
4805                sample_data: smask_data,
4806                params: ImageParams {
4807                    width: sw,
4808                    height: sh,
4809                    color_space: ImageColorSpace::DeviceGray,
4810                    bits_per_component: 8,
4811                    ctm: self.gstate.ctm,
4812                    image_matrix,
4813                    interpolate: cached.interpolate,
4814                    mask_color: None,
4815                    alpha: 1.0,
4816                    blend_mode: 0,
4817                    overprint: false,
4818                    overprint_mode: 0,
4819                    opm_paired: false,
4820                    painted_channels: 0,
4821                    alpha_is_shape: false,
4822                    rendering_intent: 0,
4823                },
4824            });
4825
4826            let mut content_dl = DisplayList::new();
4827            content_dl.push(DisplayElement::Image {
4828                sample_data,
4829                params: ImageParams {
4830                    width,
4831                    height,
4832                    image_matrix,
4833                    ..image_params
4834                },
4835            });
4836
4837            let corners = [
4838                self.gstate.ctm.transform_point(0.0, 0.0),
4839                self.gstate.ctm.transform_point(1.0, 0.0),
4840                self.gstate.ctm.transform_point(0.0, 1.0),
4841                self.gstate.ctm.transform_point(1.0, 1.0),
4842            ];
4843            let x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
4844            let y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
4845            let x_max = corners
4846                .iter()
4847                .map(|c| c.0)
4848                .fold(f64::NEG_INFINITY, f64::max);
4849            let y_max = corners
4850                .iter()
4851                .map(|c| c.1)
4852                .fold(f64::NEG_INFINITY, f64::max);
4853
4854            let parent_clip_bbox = self.current_clip_bbox();
4855            self.display_list.push(DisplayElement::SoftMasked {
4856                mask: mask_dl,
4857                content: content_dl,
4858                params: SoftMaskParams {
4859                    subtype: SoftMaskSubtype::Luminosity,
4860                    bbox: [x_min, y_min, x_max, y_max],
4861                    backdrop_color: None,
4862                    transfer_invert: false,
4863                    has_nested_mask_scope: false,
4864                    parent_clip_bbox,
4865                },
4866                mask_cache: Arc::new(Mutex::new(None)),
4867            });
4868        } else {
4869            self.display_list.push(DisplayElement::Image {
4870                sample_data,
4871                params: image_params,
4872            });
4873        }
4874        Ok(())
4875    }
4876
4877    /// Resolve an SMask (soft mask) from an image dict.
4878    /// Returns `(alpha_data, mask_width, mask_height, matte)` at the mask's native
4879    /// resolution so the caller can upscale the image if the mask is larger.
4880    /// The optional Matte array contains the pre-multiplication color (PDF spec 11.6.5.3).
4881    fn resolve_smask(
4882        &self,
4883        dict: &PdfDict,
4884        image_w: u32,
4885        image_h: u32,
4886    ) -> Result<Option<(Vec<u8>, u32, u32, Option<Vec<f64>>)>, PdfError> {
4887        let smask_ref = match dict.get(b"SMask") {
4888            Some(obj) => obj.clone(),
4889            None => return Ok(None),
4890        };
4891        let smask_obj = self.resolver.deref(&smask_ref)?;
4892        let smask_dict = match smask_obj.as_dict() {
4893            Some(d) => d,
4894            None => return Ok(None),
4895        };
4896        // Fall back to parent image dimensions when the SMask dict is
4897        // malformed (e.g. /Height missing — issue19611.pdf).
4898        let sw =
4899            validate_image_dimension(smask_dict.get_int(b"Width").or(Some(i64::from(image_w))));
4900        let sh =
4901            validate_image_dimension(smask_dict.get_int(b"Height").or(Some(i64::from(image_h))));
4902        let (Some(sw), Some(sh)) = (sw, sh) else {
4903            return Ok(None);
4904        };
4905        if validate_image_size(sw, sh).is_none() {
4906            return Ok(None);
4907        }
4908        let Some(bpc) = validate_bits_per_component(smask_dict.get_int(b"BitsPerComponent")) else {
4909            return Ok(None);
4910        };
4911        let data = self.resolver.stream_data_from_obj(&smask_ref)?;
4912
4913        // Expand non-8-bit BPC: sub-byte (1/2/4) are packed bits;
4914        // 16-bit needs downsampling to 8-bit for alpha channel use.
4915        let mut data = if bpc == 8 {
4916            data
4917        } else if bpc == 16 {
4918            // Take high byte of each 16-bit sample
4919            data.chunks(2).map(|c| c[0]).collect()
4920        } else if bpc < 8 {
4921            expand_bits_to_bytes(&data, bpc, sw, sh, 1, false)
4922        } else {
4923            data
4924        };
4925
4926        // Apply /Decode array if present (e.g. [1 0] inverts the mask)
4927        if let Some(decode) = smask_dict.get_array(b"Decode")
4928            && decode.len() >= 2
4929        {
4930            let d0 = decode[0].as_f64().unwrap_or(0.0);
4931            let d1 = decode[1].as_f64().unwrap_or(1.0);
4932            if (d0 - 1.0).abs() < 1e-6 && d1.abs() < 1e-6 {
4933                // /Decode [1 0] — invert all bytes
4934                for b in data.iter_mut() {
4935                    *b = 255 - *b;
4936                }
4937            } else if (d0).abs() > 1e-6 || (d1 - 1.0).abs() > 1e-6 {
4938                // General linear mapping: output = d0 + (d1-d0) * input/255
4939                for b in data.iter_mut() {
4940                    let v = d0 + (d1 - d0) * (*b as f64 / 255.0);
4941                    *b = (v * 255.0).round().clamp(0.0, 255.0) as u8;
4942                }
4943            }
4944        }
4945
4946        // Parse /Matte array (pre-multiplication color, PDF spec 11.6.5.3)
4947        let matte = smask_dict
4948            .get_array(b"Matte")
4949            .map(|arr| arr.iter().filter_map(|o| o.as_f64()).collect::<Vec<_>>());
4950
4951        Ok(Some((data, sw, sh, matte)))
4952    }
4953
4954    /// Resolve an explicit stencil mask (/Mask stream ref) from an image dict.
4955    /// Returns (alpha_data, mask_width, mask_height).
4956    /// When the mask is larger than the image, returns the mask at its native
4957    /// resolution so the caller can upscale the image to match (preserving the
4958    /// high-resolution edge detail from MRC scanned PDFs).
4959    fn resolve_explicit_mask(
4960        &self,
4961        dict: &PdfDict,
4962        image_w: u32,
4963        image_h: u32,
4964    ) -> Result<Option<(Vec<u8>, u32, u32)>, PdfError> {
4965        let mask_ref = match dict.get(b"Mask") {
4966            Some(obj) => obj.clone(),
4967            None => return Ok(None),
4968        };
4969        let mask_obj = self.resolver.deref(&mask_ref)?;
4970        let mask_dict = match mask_obj.as_dict() {
4971            Some(d) => d,
4972            None => return Ok(None),
4973        };
4974        let (Some(mw), Some(mh)) = (
4975            validate_image_dimension(mask_dict.get_int(b"Width")),
4976            validate_image_dimension(mask_dict.get_int(b"Height")),
4977        ) else {
4978            return Ok(None);
4979        };
4980        if validate_image_size(mw, mh).is_none() {
4981            return Ok(None);
4982        }
4983        let mask_data = self.resolver.stream_data_from_obj(&mask_ref)?;
4984
4985        // Determine mask polarity from /Decode (default [0 1]: 0=painted=opaque)
4986        let invert = if let Some(decode) = mask_dict.get_array(b"Decode") {
4987            if decode.len() >= 2 {
4988                let d0 = decode[0].as_f64().unwrap_or(0.0);
4989                // [1 0] means 1=painted (invert normal polarity)
4990                d0 > 0.5
4991            } else {
4992                false
4993            }
4994        } else {
4995            false
4996        };
4997
4998        // Expand 1-bit mask data to 8-bit alpha
4999        let row_bytes = mw.div_ceil(8);
5000        let mut alpha = vec![0u8; (mw * mh) as usize];
5001        for y in 0..mh {
5002            for x in 0..mw {
5003                let byte_idx = (y * row_bytes + x / 8) as usize;
5004                let bit_idx = 7 - (x % 8);
5005                let bit = if byte_idx < mask_data.len() {
5006                    (mask_data[byte_idx] >> bit_idx) & 1
5007                } else {
5008                    0
5009                };
5010                // In PDF, /ImageMask true with default Decode [0 1]:
5011                // bit=0 → painted (opaque), bit=1 → not painted (transparent)
5012                let opaque = if invert { bit == 1 } else { bit == 0 };
5013                alpha[(y * mw + x) as usize] = if opaque { 255 } else { 0 };
5014            }
5015        }
5016
5017        // When mask is larger than the image, return it at native resolution.
5018        // The caller will upscale the image to match, preserving the mask's
5019        // high-resolution edge detail (critical for MRC scanned PDFs).
5020        // When mask is smaller, area-average downsample to image dimensions.
5021        if mw == image_w && mh == image_h {
5022            Ok(Some((alpha, mw, mh)))
5023        } else if mw >= image_w && mh >= image_h {
5024            // Mask is larger — return at native resolution
5025            Ok(Some((alpha, mw, mh)))
5026        } else {
5027            // Mask is smaller — area-average resample to image dimensions
5028            let mut resampled = vec![0u8; (image_w * image_h) as usize];
5029            let ratio_x = mw as f32 / image_w as f32;
5030            let ratio_y = mh as f32 / image_h as f32;
5031            for y in 0..image_h {
5032                let top_f = y as f32 * ratio_y;
5033                let bottom_f = (y + 1) as f32 * ratio_y;
5034                let top = (top_f as u32).min(mh - 1);
5035                let bottom = (bottom_f.ceil() as u32).min(mh);
5036                for x in 0..image_w {
5037                    let left_f = x as f32 * ratio_x;
5038                    let right_f = (x + 1) as f32 * ratio_x;
5039                    let left = (left_f as u32).min(mw - 1);
5040                    let right = (right_f.ceil() as u32).min(mw);
5041                    let mut sum = 0.0f32;
5042                    let mut weight = 0.0f32;
5043                    for sy in top..bottom {
5044                        let py_top = sy as f32;
5045                        let py_bot = (sy + 1) as f32;
5046                        let wy = py_bot.min(bottom_f) - py_top.max(top_f);
5047                        for sx in left..right {
5048                            let px_left = sx as f32;
5049                            let px_right = (sx + 1) as f32;
5050                            let wx = px_right.min(right_f) - px_left.max(left_f);
5051                            let w = wx * wy;
5052                            sum += alpha[(sy * mw + sx) as usize] as f32 * w;
5053                            weight += w;
5054                        }
5055                    }
5056                    resampled[(y * image_w + x) as usize] = if weight > 0.0 {
5057                        (sum / weight + 0.5).min(255.0) as u8
5058                    } else {
5059                        0
5060                    };
5061                }
5062            }
5063            Ok(Some((resampled, image_w, image_h)))
5064        }
5065    }
5066
5067    /// Check if a JPXDecode image stream contains RGB+alpha (not CMYK).
5068    /// Peeks at the JP2 header metadata without re-decoding.
5069    fn is_jpx_rgba(&self, obj: &PdfObj) -> bool {
5070        #[cfg(feature = "jpx")]
5071        {
5072            if let Ok((raw, filters)) = self.resolver.raw_stream_and_filters(obj) {
5073                if filters
5074                    .iter()
5075                    .any(|f| matches!(f, crate::filters::Filter::JPXDecode))
5076                {
5077                    if let Some((color_channels, has_alpha)) = crate::filters::jpx_color_info(&raw)
5078                    {
5079                        return color_channels == 3 && has_alpha;
5080                    }
5081                }
5082            }
5083        }
5084        false
5085    }
5086
5087    /// Handle a Form XObject (recursive content stream).
5088    fn handle_form_xobject(&mut self, obj: &PdfObj, dict: &PdfDict) -> Result<(), PdfError> {
5089        if self.depth >= MAX_CONTENT_NESTING {
5090            return Err(PdfError::Other("Form XObject nesting too deep".into()));
5091        }
5092
5093        // Get form's own resources (or inherit from page)
5094        let form_resources = if let Some(res_obj) = dict.get(b"Resources") {
5095            match self.resolver.deref(res_obj)? {
5096                PdfObj::Dict(d) => d,
5097                _ => self.resources.clone(),
5098            }
5099        } else {
5100            self.resources.clone()
5101        };
5102
5103        // Form matrix
5104        let form_matrix = if let Some(vals) = deref_num_array(self.resolver, dict, b"Matrix") {
5105            if vals.len() == 6 {
5106                Matrix::new(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5])
5107            } else {
5108                Matrix::identity()
5109            }
5110        } else {
5111            Matrix::identity()
5112        };
5113
5114        // BBox clipping
5115        let bbox = if let Some(vals) = deref_num_array(self.resolver, dict, b"BBox") {
5116            if vals.len() == 4 {
5117                Some((vals[0], vals[1], vals[2], vals[3]))
5118            } else {
5119                None
5120            }
5121        } else {
5122            None
5123        };
5124
5125        // Check for transparency group
5126        let is_transparency_group = self.is_transparency_group(dict);
5127
5128        // Decompress form content stream
5129        let form_data = self.resolver.stream_data_from_obj(obj)?;
5130
5131        // Save state (including font cache — form XObjects may have different
5132        // font resources with different encodings for the same resource name)
5133        self.gstate_stack.push(self.gstate.clone());
5134        let saved_stack_depth = self.gstate_stack.len();
5135        let saved_resources = std::mem::replace(&mut self.resources, form_resources);
5136        let saved_font_cache = std::mem::take(&mut self.font_cache);
5137        let saved_current_font = self.current_font.take();
5138        let saved_cs_index = self.cs_index.take(); // invalidate — form has its own resources
5139        let saved_content_stream_ctm = self.content_stream_ctm;
5140        let saved_mc_stack = std::mem::take(&mut self.mc_stack);
5141        // Save and clear current path — forms start with an empty path per PDF spec.
5142        // Without this, an unconsumed path from the parent content stream leaks into
5143        // the form and gets painted by the first paint operator inside the form.
5144        let saved_path = std::mem::take(&mut self.current_path);
5145        let saved_point = self.current_point.take();
5146        let saved_subpath = self.subpath_start.take();
5147
5148        // Apply form matrix
5149        self.gstate.ctm = self.gstate.ctm.concat(&form_matrix);
5150
5151        // Update content stream CTM — patterns inside this form map to
5152        // the form's initial coordinate system (CTM after form matrix).
5153        self.content_stream_ctm = self.gstate.ctm;
5154
5155        if is_transparency_group {
5156            // Capture compositing parameters from the current state BEFORE
5157            // resetting alpha for the group's internal rendering.
5158            let group_blend_mode = self.gstate.blend_mode;
5159            let group_alpha = self.gstate.fill_alpha;
5160
5161            // Reset alpha and soft mask inside the group: elements render at
5162            // full opacity. The inherited alpha is applied when compositing
5163            // the group as a whole, avoiding double-application of alpha.
5164            // Clearing soft_mask prevents the parent's SMask from leaking
5165            // into q/Q flush detection inside the group — without this,
5166            // inner Q restores see the parent SMask in the saved state and
5167            // skip flushing inner SMask scopes.
5168            self.gstate.fill_alpha = 1.0;
5169            self.gstate.stroke_alpha = 1.0;
5170            self.gstate.soft_mask = None;
5171
5172            // Render group contents into a separate sub-DisplayList
5173            let mut group_list = DisplayList::new();
5174            std::mem::swap(&mut self.display_list, &mut group_list);
5175
5176            // Save and clear soft mask scope — it belongs to the parent display list
5177            let saved_scope = self.soft_mask_scope.take();
5178
5179            // Compute device-space bbox now, before interpret_stream modifies
5180            // the CTM via `cm` operators inside the form content.
5181            let device_bbox = self.compute_device_bbox(bbox);
5182
5183            // Clip to BBox inside the group's display list
5184            if let Some((x0, y0, x1, y1)) = bbox {
5185                self.push_bbox_clip(x0, y0, x1, y1);
5186            }
5187
5188            // Interpret form content into group_list (now in self.display_list)
5189            self.depth += 1;
5190            self.interpret_stream(&form_data)?;
5191            self.depth -= 1;
5192
5193            // Flush any soft mask scope opened inside the group
5194            self.flush_soft_mask();
5195
5196            // Swap back — group_list now contains the group's elements
5197            std::mem::swap(&mut self.display_list, &mut group_list);
5198
5199            // Restore parent's soft mask scope
5200            self.soft_mask_scope = saved_scope;
5201
5202            // Extract isolated and knockout flags from Group dict
5203            let isolated = self.get_group_isolated(dict);
5204            let knockout = self.get_group_knockout(dict);
5205            let color_space = self.get_group_color_space(dict);
5206
5207            // Push Group element to parent display list
5208            self.display_list.push(DisplayElement::Group {
5209                elements: group_list,
5210                params: GroupParams {
5211                    bbox: device_bbox,
5212                    isolated,
5213                    knockout,
5214                    blend_mode: group_blend_mode,
5215                    alpha: group_alpha,
5216                    color_space,
5217                },
5218            });
5219        } else {
5220            // Non-transparency-group Form XObject: render inline (existing behavior)
5221            if let Some((x0, y0, x1, y1)) = bbox {
5222                self.push_bbox_clip(x0, y0, x1, y1);
5223            }
5224
5225            // Compute visible Y range in form coordinates for early culling.
5226            // If the form is much taller than the visible area, skip offscreen
5227            // BT/ET blocks during interpretation (avoids font resolution and
5228            // glyph measurement for content that will never be rendered).
5229            let saved_cull = self.form_cull_y.take();
5230            if let Some((_x0, y0, _x1, y1)) = bbox {
5231                let form_height = (y1 - y0).abs();
5232                // Only cull if form is significantly larger than the page
5233                if form_height > 5000.0 {
5234                    // Compute visible Y range by inverse-transforming the page
5235                    // clip through the CTM. CTM maps form coords to device space.
5236                    let ctm = &self.gstate.ctm;
5237                    // For a simple scale+translate CTM (common case), inverse Y is:
5238                    // form_y = (device_y - ty) / d
5239                    if ctm.b.abs() < 1e-6 && ctm.c.abs() < 1e-6 && ctm.d.abs() > 1e-6 {
5240                        // device Y range is [0, page_height_px]
5241                        let page_h = self.initial_ctm.ty.abs();
5242                        let fy0 = (0.0 - ctm.ty) / ctm.d;
5243                        let fy1 = (page_h - ctm.ty) / ctm.d;
5244                        let (lo, hi) = if fy0 < fy1 { (fy0, fy1) } else { (fy1, fy0) };
5245                        // Add margin for text that extends above/below baseline
5246                        self.form_cull_y = Some((lo - 100.0, hi + 100.0));
5247                    }
5248                }
5249            }
5250
5251            self.depth += 1;
5252            self.interpret_stream(&form_data)?;
5253            self.depth -= 1;
5254
5255            self.form_cull_y = saved_cull;
5256        }
5257
5258        // Unwind any unbalanced q's the form content left on the stack.
5259        // Form content streams often have q without matching Q (the end of
5260        // the stream implicitly unwinds).  Without this, the extra stack
5261        // entries cause our own pop below to restore the wrong gstate,
5262        // leaking state (e.g. alpha from ExtGState) into the parent scope.
5263        while self.gstate_stack.len() > saved_stack_depth {
5264            self.gstate_stack.pop();
5265        }
5266
5267        // Restore state — check if clip needs resetting
5268        self.resources = saved_resources;
5269        self.font_cache = saved_font_cache;
5270        self.current_font = saved_current_font;
5271        self.cs_index = saved_cs_index;
5272        self.content_stream_ctm = saved_content_stream_ctm;
5273        self.current_path = saved_path;
5274        self.current_point = saved_point;
5275        self.subpath_start = saved_subpath;
5276        self.mc_stack = saved_mc_stack;
5277        if let Some(saved) = self.gstate_stack.pop() {
5278            let old_clip_version = self.gstate.clip_path_version;
5279            self.gstate = saved;
5280            // For non-group forms, restore clip if it changed
5281            if !is_transparency_group && self.gstate.clip_path_version != old_clip_version {
5282                self.restore_clip_from_stack();
5283            }
5284        }
5285
5286        Ok(())
5287    }
5288
5289    /// Check if a Form XObject dict has a /Group dict with /S /Transparency.
5290    fn is_transparency_group(&self, dict: &PdfDict) -> bool {
5291        let Some(group_obj) = dict.get(b"Group") else {
5292            return false;
5293        };
5294        let group_dict = match self.resolver.deref(group_obj) {
5295            Ok(PdfObj::Dict(d)) => d,
5296            _ => return false,
5297        };
5298        group_dict.get_name(b"S") == Some(b"Transparency")
5299    }
5300
5301    /// Extract the /I (isolated) flag from a Form XObject's /Group dict.
5302    fn get_group_isolated(&self, dict: &PdfDict) -> bool {
5303        let Some(group_obj) = dict.get(b"Group") else {
5304            return false;
5305        };
5306        let group_dict = match self.resolver.deref(group_obj) {
5307            Ok(PdfObj::Dict(d)) => d,
5308            _ => return false,
5309        };
5310        match group_dict.get(b"I") {
5311            Some(PdfObj::Bool(b)) => *b,
5312            _ => false,
5313        }
5314    }
5315
5316    /// Extract the /K (knockout) flag from a Form XObject's /Group dict.
5317    fn get_group_knockout(&self, dict: &PdfDict) -> bool {
5318        let Some(group_obj) = dict.get(b"Group") else {
5319            return false;
5320        };
5321        let group_dict = match self.resolver.deref(group_obj) {
5322            Ok(PdfObj::Dict(d)) => d,
5323            _ => return false,
5324        };
5325        match group_dict.get(b"K") {
5326            Some(PdfObj::Bool(b)) => *b,
5327            _ => false,
5328        }
5329    }
5330
5331    /// Extract the `/CS` color space from a Form XObject's `/Group` dict and
5332    /// classify it for rendering purposes. Returns `Inherited` when the entry
5333    /// is missing or refers to a color space we don't categorise here.
5334    fn get_group_color_space(
5335        &self,
5336        dict: &PdfDict,
5337    ) -> stet_graphics::display_list::GroupColorSpace {
5338        use stet_graphics::display_list::GroupColorSpace;
5339        let Some(group_obj) = dict.get(b"Group") else {
5340            return GroupColorSpace::Inherited;
5341        };
5342        let group_dict = match self.resolver.deref(group_obj) {
5343            Ok(PdfObj::Dict(d)) => d,
5344            _ => return GroupColorSpace::Inherited,
5345        };
5346        let Some(cs_obj) = group_dict.get(b"CS") else {
5347            return GroupColorSpace::Inherited;
5348        };
5349        let cs_obj = match self.resolver.deref(cs_obj) {
5350            Ok(o) => o,
5351            Err(_) => return GroupColorSpace::Inherited,
5352        };
5353        match cs_obj {
5354            PdfObj::Name(n) => match n.as_slice() {
5355                b"DeviceGray" | b"CalGray" | b"G" => GroupColorSpace::DeviceGray,
5356                b"DeviceRGB" | b"CalRGB" | b"RGB" => GroupColorSpace::DeviceRGB,
5357                b"DeviceCMYK" | b"CMYK" => GroupColorSpace::DeviceCMYK,
5358                _ => GroupColorSpace::Inherited,
5359            },
5360            PdfObj::Array(arr) => {
5361                // [/ICCBased <<stream>>] — classify by N component count.
5362                if let Some(PdfObj::Name(name)) = arr.first()
5363                    && name.as_slice() == b"ICCBased"
5364                    && let Some(stream_obj) = arr.get(1)
5365                {
5366                    let stream_obj = match self.resolver.deref(stream_obj) {
5367                        Ok(o) => o,
5368                        Err(_) => return GroupColorSpace::Inherited,
5369                    };
5370                    if let PdfObj::Stream {
5371                        dict: stream_dict, ..
5372                    } = stream_obj
5373                        && let Some(n_obj) = stream_dict.get(b"N")
5374                        && let Some(n_val) = n_obj.as_int()
5375                    {
5376                        return match n_val {
5377                            1 => GroupColorSpace::DeviceGray,
5378                            3 => GroupColorSpace::DeviceRGB,
5379                            4 => GroupColorSpace::DeviceCMYK,
5380                            _ => GroupColorSpace::Inherited,
5381                        };
5382                    }
5383                }
5384                GroupColorSpace::Inherited
5385            }
5386            _ => GroupColorSpace::Inherited,
5387        }
5388    }
5389
5390    /// Push a BBox clip path to the current display list.
5391    fn push_bbox_clip(&mut self, x0: f64, y0: f64, x1: f64, y1: f64) {
5392        let p0 = self.gstate.ctm.transform_point(x0, y0);
5393        let p1 = self.gstate.ctm.transform_point(x1, y0);
5394        let p2 = self.gstate.ctm.transform_point(x1, y1);
5395        let p3 = self.gstate.ctm.transform_point(x0, y1);
5396        let mut clip_path = PsPath::new();
5397        clip_path.segments.push(PathSegment::MoveTo(p0.0, p0.1));
5398        clip_path.segments.push(PathSegment::LineTo(p1.0, p1.1));
5399        clip_path.segments.push(PathSegment::LineTo(p2.0, p2.1));
5400        clip_path.segments.push(PathSegment::LineTo(p3.0, p3.1));
5401        clip_path.segments.push(PathSegment::ClosePath);
5402        self.display_list.push(DisplayElement::Clip {
5403            path: clip_path.clone(),
5404            params: ClipParams {
5405                fill_rule: FillRule::NonZeroWinding,
5406                ctm: Matrix::identity(),
5407                stroke_params: None,
5408            },
5409        });
5410        self.gstate
5411            .clip_stack
5412            .push((clip_path.clone(), FillRule::NonZeroWinding));
5413        self.gstate.clip_path = Some(clip_path);
5414        self.gstate.clip_path_version += 1;
5415    }
5416
5417    /// Compute device-space bounding box from form BBox + current CTM.
5418    /// Compute the device-space bounding box of the current gstate's
5419    /// clip path. Clip paths are stored in device coordinates, so this
5420    /// is just `path_bbox(clip_path)`. Returns `None` when no clip path
5421    /// is active.
5422    fn current_clip_bbox(&self) -> Option<[f64; 4]> {
5423        let path = self.gstate.clip_path.as_ref()?;
5424        let mut x_min = f64::INFINITY;
5425        let mut y_min = f64::INFINITY;
5426        let mut x_max = f64::NEG_INFINITY;
5427        let mut y_max = f64::NEG_INFINITY;
5428        for seg in &path.segments {
5429            let pts: &[(f64, f64)] = match seg {
5430                PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => &[(*x, *y)],
5431                PathSegment::CurveTo {
5432                    x1,
5433                    y1,
5434                    x2,
5435                    y2,
5436                    x3,
5437                    y3,
5438                } => &[(*x1, *y1), (*x2, *y2), (*x3, *y3)][..],
5439                PathSegment::ClosePath => &[],
5440            };
5441            for (x, y) in pts {
5442                x_min = x_min.min(*x);
5443                y_min = y_min.min(*y);
5444                x_max = x_max.max(*x);
5445                y_max = y_max.max(*y);
5446            }
5447        }
5448        if x_min.is_finite() && x_min < x_max && y_min < y_max {
5449            Some([x_min, y_min, x_max, y_max])
5450        } else {
5451            None
5452        }
5453    }
5454
5455    fn compute_device_bbox(&self, bbox: Option<(f64, f64, f64, f64)>) -> [f64; 4] {
5456        let Some((x0, y0, x1, y1)) = bbox else {
5457            // No BBox — use large sentinel
5458            return [0.0, 0.0, 1e9, 1e9];
5459        };
5460        let corners = [
5461            self.gstate.ctm.transform_point(x0, y0),
5462            self.gstate.ctm.transform_point(x1, y0),
5463            self.gstate.ctm.transform_point(x0, y1),
5464            self.gstate.ctm.transform_point(x1, y1),
5465        ];
5466        let mut min_x = f64::INFINITY;
5467        let mut min_y = f64::INFINITY;
5468        let mut max_x = f64::NEG_INFINITY;
5469        let mut max_y = f64::NEG_INFINITY;
5470        for (cx, cy) in &corners {
5471            min_x = min_x.min(*cx);
5472            min_y = min_y.min(*cy);
5473            max_x = max_x.max(*cx);
5474            max_y = max_y.max(*cy);
5475        }
5476        [min_x, min_y, max_x, max_y]
5477    }
5478
5479    /// Handle inline image (BI ... ID ... EI).
5480    fn handle_inline_image(&mut self, lexer: &mut Lexer) -> Result<(), PdfError> {
5481        // Parse image dict (abbreviated keys)
5482        let mut dict = PdfDict::new();
5483        loop {
5484            let tok = lexer.next_token()?;
5485            match tok {
5486                Token::Keyword(ref kw) if kw == b"ID" => break,
5487                Token::Eof => return Ok(()),
5488                Token::Name(key) => {
5489                    let expanded_key = expand_inline_key(&key);
5490                    let val_tok = lexer.next_token()?;
5491                    let val = match val_tok {
5492                        Token::Int(n) => PdfObj::Int(n),
5493                        Token::Real(f) => PdfObj::Real(f),
5494                        Token::Name(n) => PdfObj::Name(expand_inline_value(&n)),
5495                        Token::Bool(b) => PdfObj::Bool(b),
5496                        Token::LitString(s) | Token::HexString(s) => PdfObj::Str(s),
5497                        Token::ArrayBegin => {
5498                            let arr = Self::parse_inline_array(lexer)?;
5499                            PdfObj::Array(arr)
5500                        }
5501                        Token::DictBegin => crate::lexer::parse_dict_body(lexer)
5502                            .map(PdfObj::Dict)
5503                            .unwrap_or(PdfObj::Null),
5504                        _ => PdfObj::Null,
5505                    };
5506                    // First occurrence wins: when both abbreviated (/W) and full
5507                    // (/Width) forms are present, the first one takes precedence.
5508                    if dict.get(&expanded_key).is_none() {
5509                        dict.insert(expanded_key, val);
5510                    }
5511                }
5512                _ => {}
5513            }
5514        }
5515
5516        // Skip single whitespace byte after ID.
5517        // Treat \r\n as a single EOL delimiter (many PDF generators emit
5518        // ID\r\n before the image data).
5519        let data = lexer.data();
5520        let mut pos = lexer.pos();
5521        if pos < data.len() {
5522            if data[pos] == b'\r' {
5523                pos += 1;
5524                if pos < data.len() && data[pos] == b'\n' {
5525                    pos += 1;
5526                }
5527            } else if data[pos] == b' ' || data[pos] == b'\n' {
5528                pos += 1;
5529            }
5530        }
5531
5532        // Read image data until EI
5533        let width = validate_image_dimension(dict.get_int(b"Width")).unwrap_or(0);
5534        let height = validate_image_dimension(dict.get_int(b"Height")).unwrap_or(0);
5535        // A rejected dimension lands as 0 here, which the existing
5536        // zero-dimension paths below already treat as "no image".
5537        let (width, height) = match validate_image_size(width, height) {
5538            Some(_) => (width, height),
5539            None => (0, 0),
5540        };
5541        let is_image_mask = matches!(dict.get(b"ImageMask"), Some(PdfObj::Bool(true)));
5542        let bpc = if is_image_mask {
5543            1
5544        } else {
5545            validate_bits_per_component(dict.get_int(b"BitsPerComponent")).unwrap_or(8)
5546        };
5547
5548        let has_filter = dict.get(b"Filter").is_some() || dict.get(b"F").is_some();
5549
5550        // Check if the outermost filter is ASCII85 — its data ends with `~>`,
5551        // which is a reliable boundary marker (unlike scanning for `\nEI` which
5552        // can match false positives inside ASCII85-encoded binary data).
5553        let outermost_is_ascii85 = dict
5554            .get(b"Filter")
5555            .or_else(|| dict.get(b"F"))
5556            .map(|f| match f {
5557                PdfObj::Name(n) => n == b"ASCII85Decode" || n == b"A85",
5558                PdfObj::Array(arr) => arr
5559                    .first()
5560                    .and_then(|o| o.as_name())
5561                    .map(|n| n == b"ASCII85Decode" || n == b"A85")
5562                    .unwrap_or(false),
5563                _ => false,
5564            })
5565            .unwrap_or(false);
5566
5567        let resolved_cs = if is_image_mask {
5568            None
5569        } else if let Some(cs_obj) = dict.get(b"ColorSpace") {
5570            // For inline images, the CS value may be a resource name (e.g. /R35)
5571            // that needs lookup in the page's ColorSpace resources.
5572            let cs_resolved = if let PdfObj::Name(name) = cs_obj {
5573                // Try the cached index first, then fall back to resolving the
5574                // ColorSpace resource sub-dict directly.
5575                let from_cache = self
5576                    .cs_index
5577                    .as_ref()
5578                    .and_then(|idx| idx.get(name.as_slice()).cloned());
5579                let res_obj = from_cache.or_else(|| {
5580                    self.resolve_resource_subdict(b"ColorSpace")
5581                        .and_then(|d| d.get(name).cloned())
5582                });
5583                if let Some(ref obj) = res_obj {
5584                    resolve_color_space_obj(obj, self.resolver)
5585                } else {
5586                    resolve_color_space_obj(cs_obj, self.resolver)
5587                }
5588            } else {
5589                resolve_color_space_obj(cs_obj, self.resolver)
5590            };
5591            match cs_resolved {
5592                Ok(resolved) => Some(resolved),
5593                Err(_) => Some(ResolvedColorSpace::DeviceGray),
5594            }
5595        } else {
5596            Some(ResolvedColorSpace::DeviceGray)
5597        };
5598        let n_components = resolved_cs
5599            .as_ref()
5600            .map(|cs| cs.num_components() as u32)
5601            .unwrap_or(1);
5602
5603        // Calculate expected uncompressed data length (for EI boundary search)
5604        let row_bits = width * n_components.max(1) * bpc;
5605        let row_bytes = row_bits.div_ceil(8);
5606        let expected_len = (row_bytes * height) as usize;
5607
5608        // Find EI boundary — look for whitespace + "EI" + delimiter/EOF.
5609        // For compressed data (CCITT, Flate, etc.), the compressed data is smaller
5610        // than the uncompressed size, so we must search from the start of data.
5611        let start = pos;
5612        let search_from = if has_filter {
5613            start
5614        } else {
5615            start + expected_len
5616        };
5617        // First try: check for "EI" at/near the expected position (some PDFs omit
5618        // the whitespace before EI that the spec requires).
5619        let mut end = search_from;
5620        let mut found_no_ws = false;
5621        if !has_filter {
5622            // Check at expected_len-2, expected_len-1, and expected_len for "EI" without leading ws
5623            for offset in [
5624                expected_len.saturating_sub(2),
5625                expected_len.saturating_sub(1),
5626                expected_len,
5627            ] {
5628                let p = start + offset;
5629                if p + 1 < data.len()
5630                    && data[p] == b'E'
5631                    && data[p + 1] == b'I'
5632                    && (p + 2 >= data.len() || is_delimiter_or_ws(data[p + 2]))
5633                {
5634                    end = p;
5635                    found_no_ws = true;
5636                    break;
5637                }
5638            }
5639        }
5640        if !found_no_ws {
5641            if outermost_is_ascii85 {
5642                // ASCII85 data ends with `~>`. Search for that first, then find EI after it.
5643                // This avoids false-positive `\nEI` matches inside the ASCII85 data.
5644                let mut found_a85_end = false;
5645                let mut scan = search_from;
5646                while scan + 1 < data.len() {
5647                    if data[scan] == b'~' {
5648                        if data[scan + 1] == b'>' {
5649                            // Standard `~>` end-of-data marker
5650                            end = scan + 2;
5651                        } else if is_whitespace_byte(data[scan + 1]) {
5652                            // Malformed: `~` followed by whitespace (missing `>`)
5653                            // Accept if `EI` follows shortly after.
5654                            let mut probe = scan + 1;
5655                            while probe < data.len() && is_whitespace_byte(data[probe]) {
5656                                probe += 1;
5657                            }
5658                            if probe + 1 < data.len()
5659                                && data[probe] == b'E'
5660                                && data[probe + 1] == b'I'
5661                            {
5662                                end = scan + 1;
5663                            } else {
5664                                scan += 1;
5665                                continue;
5666                            }
5667                        } else {
5668                            scan += 1;
5669                            continue;
5670                        }
5671                        while end < data.len() && is_whitespace_byte(data[end]) {
5672                            end += 1;
5673                        }
5674                        // `end` should now point at 'E' of "EI"
5675                        found_a85_end = true;
5676                        found_no_ws = true;
5677                        break;
5678                    }
5679                    scan += 1;
5680                }
5681                if !found_a85_end {
5682                    // No `~>` found — fall back to standard search
5683                    while end + 2 < data.len() {
5684                        if is_whitespace_byte(data[end])
5685                            && data[end + 1] == b'E'
5686                            && data[end + 2] == b'I'
5687                            && (end + 3 >= data.len() || is_delimiter_or_ws(data[end + 3]))
5688                        {
5689                            break;
5690                        }
5691                        end += 1;
5692                    }
5693                }
5694            } else {
5695                while end + 2 < data.len() {
5696                    if is_whitespace_byte(data[end])
5697                        && data[end + 1] == b'E'
5698                        && data[end + 2] == b'I'
5699                        && (end + 3 >= data.len() || is_delimiter_or_ws(data[end + 3]))
5700                    {
5701                        break;
5702                    }
5703                    end += 1;
5704                }
5705            }
5706        }
5707
5708        let sample_data = data[start..end.min(data.len())].to_vec();
5709        // Skip past EI: "EI" is 2 bytes, plus trailing whitespace/delimiter
5710        let skip_past = if found_no_ws {
5711            // EI at `end`, skip "EI" + 1 trailing byte
5712            (end + 3).min(data.len())
5713        } else {
5714            // ws+EI at `end`, skip ws+"EI" + 1 trailing byte
5715            (end + 4).min(data.len())
5716        };
5717        lexer.set_pos(skip_past);
5718
5719        // Apply filters if present
5720        let sample_data = if has_filter {
5721            match crate::filters::parse_filters(&dict, Some(self.resolver)) {
5722                Ok((filters, parms)) if !filters.is_empty() => {
5723                    crate::filters::decode_stream(&sample_data, &filters, &parms, None)
5724                        .unwrap_or(sample_data)
5725                }
5726                _ => sample_data,
5727            }
5728        } else {
5729            sample_data
5730        };
5731
5732        // Build color space for display list
5733        let polarity = if is_image_mask {
5734            if let Some(arr) = dict.get_array(b"Decode") {
5735                let vals: Vec<f64> = arr.iter().filter_map(|o| o.as_f64()).collect();
5736                vals.len() >= 2 && vals[0] > 0.5
5737            } else {
5738                false
5739            }
5740        } else {
5741            false
5742        };
5743
5744        let image_matrix =
5745            Matrix::new(width as f64, 0.0, 0.0, -(height as f64), 0.0, height as f64);
5746
5747        // Imagemask with shading pattern fill: use SoftMasked to clip shading to mask shape
5748        if is_image_mask && self.gstate.fill_shading_pattern.is_some() {
5749            let shading_box = self.gstate.fill_shading_pattern.clone().unwrap();
5750
5751            // Convert 1-bit imagemask to 8-bit grayscale for luminosity soft mask
5752            // (white=opaque where painted, black=transparent)
5753            let row_bytes = width.div_ceil(8);
5754            let mut gray = vec![0u8; (width * height) as usize];
5755            for y in 0..height {
5756                for x in 0..width {
5757                    let byte_idx = (y * row_bytes + x / 8) as usize;
5758                    let bit_idx = 7 - (x % 8);
5759                    let bit = if byte_idx < sample_data.len() {
5760                        (sample_data[byte_idx] >> bit_idx) & 1
5761                    } else {
5762                        0
5763                    };
5764                    // Default Decode [0 1]: bit=0 → painted (opaque=255)
5765                    // Inverted [1 0]:      bit=1 → painted (opaque=255)
5766                    let painted = if polarity { bit == 1 } else { bit == 0 };
5767                    gray[(y * width + x) as usize] = if painted { 255 } else { 0 };
5768                }
5769            }
5770
5771            // Mask display list: the imagemask as a grayscale image
5772            let mut mask_dl = DisplayList::new();
5773            mask_dl.push(DisplayElement::Image {
5774                sample_data: Arc::new(gray),
5775                params: ImageParams {
5776                    width,
5777                    height,
5778                    color_space: ImageColorSpace::DeviceGray,
5779                    bits_per_component: 8,
5780                    ctm: self.gstate.ctm,
5781                    image_matrix,
5782                    interpolate: false,
5783                    mask_color: None,
5784                    alpha: 1.0,
5785                    blend_mode: 0,
5786                    overprint: false,
5787                    overprint_mode: 0,
5788                    opm_paired: false,
5789                    painted_channels: 0,
5790                    alpha_is_shape: false,
5791                    rendering_intent: 0,
5792                },
5793            });
5794
5795            // Content display list: the shading pattern
5796            let mut content_dl = DisplayList::new();
5797            for elem in shading_box.0.elements() {
5798                content_dl.push(elem.clone());
5799            }
5800
5801            // Compute device-space bbox of the image
5802            let corners = [
5803                self.gstate.ctm.transform_point(0.0, 0.0),
5804                self.gstate.ctm.transform_point(width as f64, 0.0),
5805                self.gstate.ctm.transform_point(0.0, height as f64),
5806                self.gstate.ctm.transform_point(width as f64, height as f64),
5807            ];
5808            let x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
5809            let y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
5810            let x_max = corners
5811                .iter()
5812                .map(|c| c.0)
5813                .fold(f64::NEG_INFINITY, f64::max);
5814            let y_max = corners
5815                .iter()
5816                .map(|c| c.1)
5817                .fold(f64::NEG_INFINITY, f64::max);
5818
5819            let parent_clip_bbox = self.current_clip_bbox();
5820            self.display_list.push(DisplayElement::SoftMasked {
5821                mask: mask_dl,
5822                content: content_dl,
5823                params: SoftMaskParams {
5824                    subtype: SoftMaskSubtype::Luminosity,
5825                    bbox: [x_min, y_min, x_max, y_max],
5826                    backdrop_color: None,
5827                    transfer_invert: false,
5828                    has_nested_mask_scope: false,
5829                    parent_clip_bbox,
5830                },
5831                mask_cache: Arc::new(Mutex::new(None)),
5832            });
5833            return Ok(());
5834        }
5835
5836        let color_space = if is_image_mask {
5837            ImageColorSpace::Mask {
5838                color: self.gstate.fill_color.clone(),
5839                polarity,
5840                spot_color: self.gstate.fill_spot_color.clone(),
5841            }
5842        } else {
5843            to_image_color_space(resolved_cs.as_ref().unwrap())
5844        };
5845
5846        // Expand bits if needed (but NOT for image masks — keep raw 1-bit packed data)
5847        let is_indexed = matches!(&color_space, ImageColorSpace::Indexed { .. });
5848        let sample_data = if !is_image_mask && bpc != 8 && bpc != 0 {
5849            expand_bits_to_bytes(&sample_data, bpc, width, height, n_components, is_indexed)
5850        } else {
5851            sample_data
5852        };
5853
5854        // PDF/X CMYK group: route DeviceGray / Separation-with-gray-alt /
5855        // DeviceN-with-gray-alt images through the K plate so they composite
5856        // equivalently to DeviceCMYK 0/0/0/(1−g).
5857        let (color_space, sample_data) = if !is_image_mask {
5858            self.cmyk_group_promote_image(color_space, sample_data, width, height)
5859        } else {
5860            (color_space, sample_data)
5861        };
5862
5863        // Register ICC profile with the cache so the rasterizer can find it
5864        // by hash.  Color conversion itself is deferred to samples_to_rgba().
5865        if !is_image_mask {
5866            if let Some(ref rcs) = resolved_cs {
5867                register_icc_profile(rcs, &mut self.icc_cache);
5868            }
5869        }
5870
5871        self.display_list.push(DisplayElement::Image {
5872            sample_data: Arc::new(sample_data),
5873            params: ImageParams {
5874                width,
5875                height,
5876                color_space,
5877                bits_per_component: 8,
5878                ctm: self.gstate.ctm,
5879                image_matrix,
5880                interpolate: false,
5881                mask_color: None,
5882                alpha: self.gstate.fill_alpha,
5883                blend_mode: self.gstate.blend_mode,
5884                overprint: self.gstate.overprint,
5885                overprint_mode: self.gstate.overprint_mode,
5886                opm_paired: self.gstate.opm_paired,
5887                painted_channels: resolved_cs
5888                    .as_ref()
5889                    .map(painted_channels_for_cs)
5890                    .unwrap_or(self.gstate.fill_painted_channels),
5891                alpha_is_shape: self.gstate.alpha_is_shape,
5892                rendering_intent: 0,
5893            },
5894        });
5895
5896        Ok(())
5897    }
5898
5899    /// Apply ExtGState dictionary entries.
5900    fn apply_ext_gstate(&mut self, name: &[u8]) -> Result<(), PdfError> {
5901        let ext_dict = self
5902            .resolve_resource_subdict(b"ExtGState")
5903            .ok_or(PdfError::Other("no ExtGState resources".into()))?;
5904        let gs_ref = ext_dict.get(name).ok_or_else(|| {
5905            PdfError::Other(format!(
5906                "ExtGState /{} not found",
5907                String::from_utf8_lossy(name)
5908            ))
5909        })?;
5910        let gs_obj = self.resolver.deref(gs_ref)?;
5911        let gs_dict = gs_obj
5912            .as_dict()
5913            .ok_or(PdfError::Other("ExtGState is not a dict".into()))?;
5914
5915        // Apply known keys
5916        if let Some(lw) = gs_dict.get_f64(b"LW") {
5917            self.gstate.line_width = lw;
5918        }
5919        if let Some(lc) = gs_dict.get_int(b"LC")
5920            && let Some(cap) = LineCap::from_i32(lc as i32)
5921        {
5922            self.gstate.line_cap = cap;
5923        }
5924        if let Some(lj) = gs_dict.get_int(b"LJ")
5925            && let Some(join) = LineJoin::from_i32(lj as i32)
5926        {
5927            self.gstate.line_join = join;
5928        }
5929        if let Some(ml) = gs_dict.get_f64(b"ML") {
5930            self.gstate.miter_limit = ml;
5931        }
5932        if let Some(fl) = gs_dict.get_f64(b"FL") {
5933            self.gstate.flatness = fl;
5934        }
5935        if let Some(PdfObj::Bool(sa)) = gs_dict.get(b"SA") {
5936            self.gstate.stroke_adjust = *sa;
5937        }
5938        // Always parse OPM — it affects whether CMYK all-zero is transparent,
5939        // which is needed even without full overprint simulation.
5940        let has_opm = gs_dict.get(b"OPM").is_some();
5941        if let Some(opm) = gs_dict.get_int(b"OPM") {
5942            self.gstate.overprint_mode = opm as i32;
5943        }
5944        let has_op_flag = gs_dict.get(b"OP").is_some() || gs_dict.get(b"op").is_some();
5945        if self.overprint_enabled {
5946            if let Some(PdfObj::Bool(op)) = gs_dict.get(b"OP") {
5947                self.gstate.overprint = *op;
5948                // OP also sets stroke overprint
5949                self.gstate.overprint_stroke = *op;
5950            }
5951            if let Some(PdfObj::Bool(op)) = gs_dict.get(b"op") {
5952                self.gstate.overprint = *op;
5953            }
5954        }
5955        // Track whether the current ExtGState signals "strict overprint",
5956        // meaning the strict OPM-1 "zero-source preserves backdrop" rule
5957        // applies. Two patterns count as a strict signal:
5958        //   1. /OPM together with /op|/OP in the same dict (Adobe Illustrator
5959        //      asserts both when emitting overprint).
5960        //   2. /OP and /op together in the same dict — legacy "old-style"
5961        //      overprint that drove both stroke and fill, used by GWG 12.0
5962        //      White Overprint where /GS6 sets `/OP true /op true`.
5963        // An /op set in isolation, with OPM merely inherited (e.g. 2495.pdf
5964        // page 5 page-icon: /R11 sets /OPM 1, /R20 sets only /op), falls
5965        // back to legacy "zero = knockout" semantics so `0 0 0 0 k` paints
5966        // still act as a white knockout.
5967        let has_op_upper = gs_dict.get(b"OP").is_some();
5968        let has_op_lower = gs_dict.get(b"op").is_some();
5969        let strict_signal = (has_opm && has_op_flag) || (has_op_upper && has_op_lower);
5970        if strict_signal {
5971            self.gstate.opm_paired = true;
5972        } else if has_opm || has_op_flag {
5973            self.gstate.opm_paired = false;
5974        }
5975        if let Some(ca) = gs_dict.get_f64(b"CA") {
5976            self.gstate.stroke_alpha = ca;
5977        }
5978        if let Some(ca) = gs_dict.get_f64(b"ca") {
5979            self.gstate.fill_alpha = ca;
5980        }
5981        if let Some(b) = gs_dict.get_bool(b"AIS") {
5982            self.gstate.alpha_is_shape = b;
5983        }
5984        if let Some(b) = gs_dict.get_bool(b"TK") {
5985            self.gstate.text_knockout = b;
5986        }
5987        // Rendering intent — feeds into the per-intent ICC chain dispatch.
5988        if let Some(PdfObj::Name(ri)) = gs_dict.get(b"RI") {
5989            self.gstate.rendering_intent = match ri.as_slice() {
5990                b"Perceptual" => 0,
5991                b"RelativeColorimetric" => 1,
5992                b"Saturation" => 2,
5993                b"AbsoluteColorimetric" => 3,
5994                _ => 0,
5995            };
5996        }
5997
5998        // Blend mode
5999        if let Some(bm) = gs_dict.get(b"BM") {
6000            let bm = self.resolver.deref(bm).unwrap_or_else(|_| bm.clone());
6001            match &bm {
6002                PdfObj::Name(name) => {
6003                    self.gstate.blend_mode = blend_mode_from_name(name);
6004                }
6005                PdfObj::Array(arr) => {
6006                    for obj in arr {
6007                        if let PdfObj::Name(name) = obj {
6008                            let mode = blend_mode_from_name(name);
6009                            if mode != 0 || name.as_slice() == b"Normal" {
6010                                self.gstate.blend_mode = mode;
6011                                break;
6012                            }
6013                        }
6014                    }
6015                }
6016                _ => {}
6017            }
6018        }
6019
6020        // Dash pattern
6021        if let Some(d_arr) = gs_dict.get_array(b"D")
6022            && d_arr.len() == 2
6023            && let (Some(arr), Some(offset)) = (d_arr[0].as_array(), d_arr[1].as_f64())
6024        {
6025            let array: Vec<f64> = arr.iter().filter_map(|o| o.as_f64()).collect();
6026            self.gstate.dash_pattern = DashPattern { array, offset };
6027        }
6028
6029        // Font — array [font_ref size] sets both font and size
6030        if let Some(font_arr) = gs_dict.get_array(b"Font")
6031            && font_arr.len() == 2
6032            && let Some(size) = font_arr[1].as_f64()
6033        {
6034            self.gstate.font_size = size;
6035            // Resolve the font object from the first array element
6036            let font_ref = &font_arr[0];
6037            // Use object number as cache key, or a fallback synthetic name
6038            let cache_key = if let PdfObj::Ref(obj_num, _) = font_ref {
6039                format!("__gs_font_{obj_num}").into_bytes()
6040            } else {
6041                b"__gs_font_inline".to_vec()
6042            };
6043            if let Some(cached) = self.font_cache.get(&cache_key) {
6044                self.current_font = Some(Arc::clone(cached));
6045            } else {
6046                match font::resolve_font(self.resolver, font_ref, self.font_provider.as_ref()) {
6047                    Ok(font) => {
6048                        let arc = Arc::new(font);
6049                        self.font_cache.insert(cache_key, Arc::clone(&arc));
6050                        self.current_font = Some(arc);
6051                    }
6052                    Err(e) => {
6053                        eprintln!("warning: ExtGState Font: {e}");
6054                    }
6055                }
6056            }
6057        }
6058
6059        // Transfer function: TR2 takes priority over TR
6060        if let Some(tr_obj) = gs_dict.get(b"TR2").or_else(|| gs_dict.get(b"TR")) {
6061            self.gstate.transfer = self.parse_transfer_function(tr_obj)?;
6062        }
6063
6064        // Soft mask
6065        if let Some(smask_obj) = gs_dict.get(b"SMask") {
6066            let smask_obj = self.resolver.deref(smask_obj)?;
6067            match &smask_obj {
6068                PdfObj::Name(n) if n.as_slice() == b"None" => {
6069                    self.flush_soft_mask();
6070                    self.gstate.soft_mask = None;
6071                }
6072                PdfObj::Dict(d) => {
6073                    self.flush_soft_mask();
6074                    match self.resolve_soft_mask(d) {
6075                        Ok(sm) => {
6076                            let start_index = self.display_list.len();
6077                            self.gstate.soft_mask = Some(sm.clone());
6078                            self.gstate.smask_gen += 1;
6079                            self.soft_mask_scope = Some(SoftMaskScope {
6080                                start_index,
6081                                mask: sm,
6082                            });
6083                        }
6084                        Err(e) => {
6085                            eprintln!("warning: SMask resolve error: {}", e);
6086                        }
6087                    }
6088                }
6089                _ => {}
6090            }
6091        }
6092
6093        Ok(())
6094    }
6095
6096    /// Flush the current soft mask scope: wrap accumulated elements in SoftMasked.
6097    fn flush_soft_mask(&mut self) {
6098        if let Some(scope) = self.soft_mask_scope.take()
6099            && self.display_list.len() > scope.start_index
6100        {
6101            let content = self.display_list.split_off(scope.start_index);
6102
6103            // Skip conditions:
6104            //
6105            // 1. The mask display list is empty: there is no mask raster
6106            //    to compute, so the SoftMasked element would have nothing
6107            //    to do — emit content directly.
6108            //
6109            // 2. (5795.pdf-style escape hatch) The mask form's bbox does
6110            //    not have *substantial* overlap with the content's painted
6111            //    area AND the backdrop is black. In this case the mask, if
6112            //    honored literally, would multiply most content pixels by
6113            //    0 (the BC fallback) and effectively erase the content.
6114            //    Both Ghostscript and Poppler short-circuit this and render
6115            //    the content as if no mask were present. We match that for:
6116            //
6117            //    - 5795.pdf: botanical background image. The mask `/G` form
6118            //      bbox sits at PDF x=[-767.7, 0.29] (essentially all
6119            //      negative-x). The content image is at PDF x=[0, 768].
6120            //      The bboxes touch only in the [0, 0.29 pt] sliver — no
6121            //      substantial overlap.
6122            //    - 907.pdf p24: gradient callout arrows. The SMask `/G`
6123            //      form bbox is at PDF y=[-155.6, -52.4] (negative-y, off
6124            //      the bottom of the page). The content image is at PDF
6125            //      y=[423, 501] — disjoint from the mask form bbox.
6126            //
6127            //    The CRITICAL distinction from 5296.pdf's /GS29 + /Fm30
6128            //    bug is that in 5296 the mask form's bbox at PDF
6129            //    x=[-519, -8] overlaps the content rectangle's bbox at
6130            //    PDF x=[-390, 121] across ~382 pt of x. The mask is
6131            //    *meant* to apply to the content — the visible portion of
6132            //    the content (x=[0, 121]) just falls outside the mask's
6133            //    spatial extent and should be erased by the BC=0 fallback.
6134            //
6135            //    The threshold of 2 device pt in both dimensions matches
6136            //    the original `mask_has_meaningful_overlap` threshold but
6137            //    measures against the content bbox (not the parent clip
6138            //    bbox), which is what actually distinguishes the two
6139            //    cases.
6140            let content_bbox = self.content_paint_bbox(&content);
6141            let drop_shadow_skip = scope.mask.backdrop_color == Some([0.0, 0.0, 0.0])
6142                && content_bbox
6143                    .map(|c| !bboxes_overlap_substantially(&c, &scope.mask.bbox, 2.0))
6144                    .unwrap_or(false);
6145            let skip = scope.mask.mask_list.is_empty() || drop_shadow_skip;
6146            if skip {
6147                for elem in content.into_elements() {
6148                    self.display_list.push(elem);
6149                }
6150            } else {
6151                // Collect Clip/InitClip elements to replay after SoftMasked.
6152                // The mask scope may capture clip operations that were established
6153                // in gsave levels that extend beyond the scope — these must remain
6154                // in the main display list to affect subsequent rendering.
6155                let clip_replay: Vec<DisplayElement> = content
6156                    .elements()
6157                    .iter()
6158                    .filter(|e| matches!(e, DisplayElement::Clip { .. } | DisplayElement::InitClip))
6159                    .cloned()
6160                    .collect();
6161                let parent_clip_bbox = self.current_clip_bbox();
6162                self.display_list.push(DisplayElement::SoftMasked {
6163                    mask: scope.mask.mask_list,
6164                    content,
6165                    params: SoftMaskParams {
6166                        subtype: scope.mask.subtype,
6167                        bbox: scope.mask.bbox,
6168                        backdrop_color: scope.mask.backdrop_color,
6169                        transfer_invert: scope.mask.transfer_invert,
6170                        has_nested_mask_scope: scope.mask.has_nested_mask_scope,
6171                        parent_clip_bbox,
6172                    },
6173                    mask_cache: Arc::new(Mutex::new(None)),
6174                });
6175                for elem in clip_replay {
6176                    self.display_list.push(elem);
6177                }
6178            }
6179        }
6180    }
6181
6182    /// Resolve the number of color components from a form XObject's /Group/CS.
6183    /// Both /Group and /CS may be indirect references.
6184    fn resolve_group_cs_comps(&self, form_dict: &PdfDict) -> usize {
6185        let cs_name_to_comps = |cs: &[u8]| -> usize {
6186            match cs {
6187                b"DeviceGray" => 1,
6188                b"DeviceRGB" => 3,
6189                b"DeviceCMYK" => 4,
6190                _ => 0,
6191            }
6192        };
6193
6194        let grp_obj = match form_dict.get(b"Group") {
6195            Some(obj) => obj,
6196            None => return 0,
6197        };
6198
6199        // Get the Group dict — may be inline or indirect
6200        let resolved_grp;
6201        let grp = if let Some(d) = grp_obj.as_dict() {
6202            d
6203        } else if let Ok(r) = self.resolver.deref(grp_obj) {
6204            resolved_grp = r;
6205            match resolved_grp.as_dict() {
6206                Some(d) => d,
6207                None => return 0,
6208            }
6209        } else {
6210            return 0;
6211        };
6212
6213        // Get CS — may be inline name or indirect
6214        if let Some(cs) = grp.get_name(b"CS") {
6215            return cs_name_to_comps(cs);
6216        }
6217        if let Some(cs_obj) = grp.get(b"CS") {
6218            if let Ok(cs_resolved) = self.resolver.deref(cs_obj) {
6219                if let Some(cs) = cs_resolved.as_name() {
6220                    return cs_name_to_comps(cs);
6221                }
6222            }
6223        }
6224        0
6225    }
6226
6227    /// Compute the union of paint bounds for the elements in `content`.
6228    /// Returns `None` if no element contributes a bounded paint extent.
6229    ///
6230    /// This is a coarse estimate used by `flush_soft_mask` to decide
6231    /// whether the mask form's bbox overlaps the content. Only the most
6232    /// common element kinds are inspected (Fill, Stroke, Image, Group,
6233    /// SoftMasked, PatternFill); shading elements and text are treated
6234    /// as contributing no bound (we conservatively skip them, which
6235    /// for the drop-shadow-skip heuristic means we'll trust the result
6236    /// from the other elements present).
6237    fn content_paint_bbox(&self, content: &DisplayList) -> Option<[f64; 4]> {
6238        let mut x_min = f64::INFINITY;
6239        let mut y_min = f64::INFINITY;
6240        let mut x_max = f64::NEG_INFINITY;
6241        let mut y_max = f64::NEG_INFINITY;
6242        let mut grow = |bx: [f64; 4]| {
6243            x_min = x_min.min(bx[0].min(bx[2]));
6244            y_min = y_min.min(bx[1].min(bx[3]));
6245            x_max = x_max.max(bx[0].max(bx[2]));
6246            y_max = y_max.max(bx[1].max(bx[3]));
6247        };
6248        for elem in content.elements() {
6249            match elem {
6250                DisplayElement::Fill { path, .. }
6251                | DisplayElement::Stroke { path, .. }
6252                | DisplayElement::Clip { path, .. } => {
6253                    let mut px_min = f64::INFINITY;
6254                    let mut py_min = f64::INFINITY;
6255                    let mut px_max = f64::NEG_INFINITY;
6256                    let mut py_max = f64::NEG_INFINITY;
6257                    for seg in &path.segments {
6258                        let pts: &[(f64, f64)] = match seg {
6259                            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => &[(*x, *y)],
6260                            PathSegment::CurveTo {
6261                                x1,
6262                                y1,
6263                                x2,
6264                                y2,
6265                                x3,
6266                                y3,
6267                            } => &[(*x1, *y1), (*x2, *y2), (*x3, *y3)][..],
6268                            PathSegment::ClosePath => &[],
6269                        };
6270                        for (x, y) in pts {
6271                            px_min = px_min.min(*x);
6272                            py_min = py_min.min(*y);
6273                            px_max = px_max.max(*x);
6274                            py_max = py_max.max(*y);
6275                        }
6276                    }
6277                    if px_min.is_finite() && px_min < px_max && py_min < py_max {
6278                        grow([px_min, py_min, px_max, py_max]);
6279                    }
6280                }
6281                DisplayElement::Image { params, .. } => {
6282                    // Image is drawn into the unit square mapped through
6283                    // params.ctm. Compute the four corner positions.
6284                    let ctm = &params.ctm;
6285                    let corners = [
6286                        ctm.transform_point(0.0, 0.0),
6287                        ctm.transform_point(1.0, 0.0),
6288                        ctm.transform_point(0.0, 1.0),
6289                        ctm.transform_point(1.0, 1.0),
6290                    ];
6291                    let mut ix_min = f64::INFINITY;
6292                    let mut iy_min = f64::INFINITY;
6293                    let mut ix_max = f64::NEG_INFINITY;
6294                    let mut iy_max = f64::NEG_INFINITY;
6295                    for (cx, cy) in &corners {
6296                        ix_min = ix_min.min(*cx);
6297                        iy_min = iy_min.min(*cy);
6298                        ix_max = ix_max.max(*cx);
6299                        iy_max = iy_max.max(*cy);
6300                    }
6301                    grow([ix_min, iy_min, ix_max, iy_max]);
6302                }
6303                DisplayElement::Group { params, .. } => {
6304                    grow(params.bbox);
6305                }
6306                DisplayElement::SoftMasked { params, .. } => {
6307                    grow(params.bbox);
6308                }
6309                _ => {} // Shadings, patterns, text — coarse estimate skips them.
6310            }
6311        }
6312        if x_min.is_finite() && x_min < x_max && y_min < y_max {
6313            Some([x_min, y_min, x_max, y_max])
6314        } else {
6315            None
6316        }
6317    }
6318
6319    /// Resolve a soft mask dictionary into a SoftMask.
6320    fn resolve_soft_mask(&mut self, dict: &PdfDict) -> Result<graphics_state::SoftMask, PdfError> {
6321        // The mask group's form is interpreted directly below rather than
6322        // through `handle_form_xobject`, so it does not inherit that path's
6323        // guard. A form whose content re-selects the ExtGState naming this
6324        // same mask would otherwise recurse until the stack is exhausted.
6325        if self.depth >= MAX_CONTENT_NESTING {
6326            return Err(PdfError::Other("soft mask nesting too deep".into()));
6327        }
6328
6329        // Parse /S (subtype): Alpha or Luminosity (default Luminosity)
6330        let subtype = match dict.get_name(b"S") {
6331            Some(b"Alpha") => SoftMaskSubtype::Alpha,
6332            _ => SoftMaskSubtype::Luminosity,
6333        };
6334
6335        // Parse /G (Form XObject) — required
6336        let g_ref = dict
6337            .get(b"G")
6338            .ok_or_else(|| PdfError::Other("SMask missing /G".into()))?;
6339        let g_obj = self.resolver.deref(g_ref)?;
6340        let g_dict = g_obj
6341            .as_dict()
6342            .ok_or_else(|| PdfError::Other("SMask /G is not a dict".into()))?;
6343
6344        // Get form BBox
6345        let bbox_tuple = if let Some(vals) = deref_num_array(self.resolver, g_dict, b"BBox") {
6346            if vals.len() == 4 {
6347                Some((vals[0], vals[1], vals[2], vals[3]))
6348            } else {
6349                None
6350            }
6351        } else {
6352            None
6353        };
6354
6355        // Form matrix
6356        let form_matrix = if let Some(vals) = deref_num_array(self.resolver, g_dict, b"Matrix") {
6357            if vals.len() == 6 {
6358                Matrix::new(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5])
6359            } else {
6360                Matrix::identity()
6361            }
6362        } else {
6363            Matrix::identity()
6364        };
6365
6366        // Get form resources
6367        let form_resources = if let Some(res_obj) = g_dict.get(b"Resources") {
6368            match self.resolver.deref(res_obj)? {
6369                PdfObj::Dict(d) => d,
6370                _ => self.resources.clone(),
6371            }
6372        } else {
6373            self.resources.clone()
6374        };
6375
6376        // Render the form into a display list
6377        let form_data = self.resolver.stream_data_from_obj(g_ref)?;
6378
6379        // Save state and render (clear font cache — the mask form may have
6380        // different font resources with the same resource names as the parent)
6381        self.gstate_stack.push(self.gstate.clone());
6382        let saved_resources = std::mem::replace(&mut self.resources, form_resources);
6383        let saved_font_cache = std::mem::take(&mut self.font_cache);
6384        let saved_current_font2 = self.current_font.take();
6385        let saved_cs_index2 = self.cs_index.take();
6386        let saved_display_list = std::mem::replace(&mut self.display_list, DisplayList::new());
6387        let saved_scope = self.soft_mask_scope.take();
6388        let saved_content_stream_ctm = self.content_stream_ctm;
6389        let saved_mc_stack = std::mem::take(&mut self.mc_stack);
6390
6391        // Apply form matrix to CTM
6392        self.gstate.ctm = self.gstate.ctm.concat(&form_matrix);
6393        // Update content_stream_ctm so shading patterns inside the mask form
6394        // use the form's coordinate system, not the parent's.
6395        self.content_stream_ctm = self.gstate.ctm;
6396
6397        // Reset alpha and soft mask: the mask form is an independent rendering
6398        // context. Without this, the parent's ca/CA leak into the mask form
6399        // (e.g. ca=0.9 making mask form elements semi-transparent).
6400        self.gstate.fill_alpha = 1.0;
6401        self.gstate.stroke_alpha = 1.0;
6402        self.gstate.soft_mask = None;
6403
6404        // Compute device-space bbox now, before interpret_stream modifies the
6405        // CTM via `cm` operators. The form BBox is in the form's coordinate
6406        // system (after form matrix), not the content's rotated space.
6407        let device_bbox = self.compute_device_bbox(bbox_tuple);
6408
6409        // Clip to BBox
6410        if let Some((x0, y0, x1, y1)) = bbox_tuple {
6411            self.push_bbox_clip(x0, y0, x1, y1);
6412        }
6413
6414        // Disable ICC CMYK conversion inside soft mask forms. ICC profiles
6415        // map 100% K to non-zero RGB (e.g. (44,41,42)), giving non-zero
6416        // luminosity where the mask should be opaque black. PLRM formulas
6417        // produce exact (0,0,0) for CMYK (0,0,0,1), yielding correct
6418        // luminosity = 0 for the mask.
6419        let saved_cmyk_hash = self.icc_cache.suspend_default_cmyk();
6420        // Suppress the DeviceGray-to-K-only promotion while parsing the SMask
6421        // form. The promotion produces a DeviceCMYK image whose ICC
6422        // conversion happens at render time (after the suspension above is
6423        // restored), so the parse-time suspend never sees it. Skipping the
6424        // promotion here keeps the SMask source's display list in its
6425        // original DeviceGray form, which the renderer paints gray-to-gray
6426        // without ICC — luminosity = g/255 exactly (regressed
6427        // `pdf_samples/2495.pdf`'s right-side SMask images).
6428        let saved_in_smask_form = self.in_smask_form;
6429        self.in_smask_form = true;
6430
6431        let saved_nested_mask_flush_count = self.nested_mask_flush_count;
6432        self.depth += 1;
6433        let _ = self.interpret_stream(&form_data);
6434        self.depth -= 1;
6435
6436        self.in_smask_form = saved_in_smask_form;
6437        self.icc_cache.restore_default_cmyk(saved_cmyk_hash);
6438
6439        // Check if Q handlers flushed any gs-set nested soft mask scopes
6440        // during interpretation. This is tracked via a counter that's
6441        // incremented in op_big_q when smask_gen changes (NOT by image-level
6442        // SMasks or other SoftMasked element sources).
6443        let has_nested_mask_scope = self.nested_mask_flush_count > saved_nested_mask_flush_count;
6444
6445        // Flush any soft mask scope opened inside the mask form
6446        self.flush_soft_mask();
6447
6448        let mask_list = std::mem::replace(&mut self.display_list, saved_display_list);
6449        self.soft_mask_scope = saved_scope;
6450        self.content_stream_ctm = saved_content_stream_ctm;
6451        self.resources = saved_resources;
6452        self.font_cache = saved_font_cache;
6453        self.current_font = saved_current_font2;
6454        self.cs_index = saved_cs_index2;
6455        self.mc_stack = saved_mc_stack;
6456        if let Some(saved) = self.gstate_stack.pop() {
6457            self.gstate = saved;
6458        }
6459
6460        // Parse /BC (backdrop color) — may be inline array or indirect reference.
6461        // BC is in the group's color space, so we check /G's /Group/CS to know
6462        // how many components to use (rather than guessing from array length).
6463        let group_n_comps = self.resolve_group_cs_comps(g_dict);
6464
6465        let backdrop_color = if let Some(bc_obj) = dict.get(b"BC") {
6466            let bc_resolved = self.resolver.deref(bc_obj).ok();
6467            let bc_arr = bc_resolved
6468                .as_ref()
6469                .and_then(|o| o.as_array())
6470                .or_else(|| bc_obj.as_array());
6471            if let Some(arr) = bc_arr {
6472                let vals: Vec<f64> = arr.iter().filter_map(|o| o.as_f64()).collect();
6473                if group_n_comps == 1 && !vals.is_empty() {
6474                    // DeviceGray: first value is gray level
6475                    Some([vals[0], vals[0], vals[0]])
6476                } else if group_n_comps == 4 && vals.len() >= 4 {
6477                    // DeviceCMYK
6478                    let c = vals[0];
6479                    let m = vals[1];
6480                    let y = vals[2];
6481                    let k = vals[3];
6482                    Some([
6483                        (1.0 - c) * (1.0 - k),
6484                        (1.0 - m) * (1.0 - k),
6485                        (1.0 - y) * (1.0 - k),
6486                    ])
6487                } else if vals.len() >= 3 {
6488                    Some([vals[0], vals[1], vals[2]])
6489                } else if vals.len() == 1 {
6490                    Some([vals[0], vals[0], vals[0]])
6491                } else {
6492                    None
6493                }
6494            } else {
6495                None
6496            }
6497        } else {
6498            // No /BC specified: default is all zeros in the group color space.
6499            // For DeviceCMYK, [0,0,0,0] = no ink = white → RGB [1,1,1].
6500            // For DeviceRGB/Gray, [0,0,0] = black → RGB [0,0,0] (luminosity 0).
6501            if group_n_comps == 4 {
6502                Some([1.0, 1.0, 1.0])
6503            } else {
6504                None
6505            }
6506        };
6507
6508        // Check for /TR (transfer function). The common case is {1 exch sub}
6509        // which inverts the mask values. Detect this and set a flag.
6510        // Check for /TR (transfer function). The common case is {1 exch sub}
6511        // which inverts the mask values. Detect this by reading the stream content.
6512        let transfer_invert = if let Some(tr_obj) = dict.get(b"TR") {
6513            if let Ok(tr_data) = self.resolver.stream_data_from_obj(tr_obj) {
6514                let trimmed: Vec<u8> = tr_data
6515                    .iter()
6516                    .copied()
6517                    .filter(|b| !b.is_ascii_whitespace())
6518                    .collect();
6519                let s = String::from_utf8_lossy(&trimmed);
6520                s.contains("exchsub")
6521            } else {
6522                false
6523            }
6524        } else {
6525            false
6526        };
6527
6528        // When BC is specified and produces a non-zero luminosity value, the
6529        // soft mask extends beyond the form's BBox — the BC backdrop fills all
6530        // areas outside the form content. Expand the bbox to cover the entire
6531        // viewport so the renderer doesn't crop the mask to the form's BBox.
6532        let effective_bbox = if let Some(bc) = &backdrop_color {
6533            let bc_lum = 0.2126 * bc[0] + 0.7152 * bc[1] + 0.0722 * bc[2];
6534            let bc_byte = (bc_lum * 255.0 + 0.5) as u8;
6535            // After transfer inversion, the effective mask value is 255 - bc_byte.
6536            // If either the original or inverted value is non-zero, the mask has
6537            // effect outside the form BBox.
6538            let effective = if transfer_invert {
6539                255 - bc_byte
6540            } else {
6541                bc_byte
6542            };
6543            if effective > 0 {
6544                [0.0, 0.0, 1e9, 1e9]
6545            } else {
6546                device_bbox
6547            }
6548        } else {
6549            device_bbox
6550        };
6551
6552        Ok(graphics_state::SoftMask {
6553            mask_list,
6554            subtype,
6555            bbox: effective_bbox,
6556            backdrop_color,
6557            transfer_invert,
6558            has_nested_mask_scope,
6559        })
6560    }
6561
6562    /// Parse a TR/TR2 value into a TransferState.
6563    ///
6564    /// PDF spec: TR/TR2 can be a single function (applied to all channels),
6565    /// an array of 4 functions [R, G, B, Gray], or /Identity.
6566    fn parse_transfer_function(
6567        &self,
6568        obj: &PdfObj,
6569    ) -> Result<stet_graphics::device::TransferState, PdfError> {
6570        use crate::resources::function::PdfFunction;
6571        use stet_graphics::device::TransferState;
6572
6573        let obj = self.resolver.deref(obj)?;
6574
6575        // /Identity or /Default → no transfer
6576        if let Some(name) = obj.as_name()
6577            && (name == b"Identity" || name == b"Default")
6578        {
6579            return Ok(TransferState::default());
6580        }
6581
6582        // Array of 4 functions [R, G, B, Gray]
6583        if let PdfObj::Array(arr) = &obj
6584            && arr.len() == 4
6585        {
6586            let mut tables: [Option<Arc<Vec<f64>>>; 4] = Default::default();
6587            for (i, fn_obj) in arr.iter().enumerate() {
6588                let fn_obj = self.resolver.deref(fn_obj)?;
6589                if let Some(name) = fn_obj.as_name()
6590                    && (name == b"Identity" || name == b"Default")
6591                {
6592                    continue; // None = identity
6593                }
6594                if let Ok(func) = PdfFunction::parse(&fn_obj, self.resolver) {
6595                    tables[i] = Some(Arc::new(sample_transfer_function(&func)));
6596                }
6597            }
6598            return Ok(TransferState {
6599                gray: None,
6600                color: Some(tables),
6601            });
6602        }
6603
6604        // Single function → apply to all channels via gray
6605        if let Ok(func) = PdfFunction::parse(&obj, self.resolver) {
6606            let table = Arc::new(sample_transfer_function(&func));
6607            return Ok(TransferState {
6608                gray: Some(table),
6609                color: None,
6610            });
6611        }
6612
6613        Ok(TransferState::default())
6614    }
6615
6616    // === Shading operator ===
6617
6618    fn op_sh(&mut self) -> Result<(), PdfError> {
6619        let name = self
6620            .operand_stack
6621            .last()
6622            .and_then(|o| o.as_name())
6623            .ok_or(PdfError::Other("sh: expected name".into()))?
6624            .to_vec();
6625
6626        let shading_dict = self
6627            .resolve_resource_subdict(b"Shading")
6628            .ok_or(PdfError::Other("no Shading resources".into()))?;
6629        let sh_ref = shading_dict.get(&name).ok_or_else(|| {
6630            PdfError::Other(format!(
6631                "Shading /{} not found",
6632                String::from_utf8_lossy(&name)
6633            ))
6634        })?;
6635        let sh_ref_clone = sh_ref.clone();
6636        let sh_obj = self.resolver.deref(sh_ref)?;
6637        let sh_dict = sh_obj
6638            .as_dict()
6639            .ok_or(PdfError::Other("Shading is not a dict".into()))?;
6640
6641        crate::resources::shading::handle_shading(
6642            &sh_ref_clone,
6643            sh_dict,
6644            &self.gstate,
6645            self.resolver,
6646            &mut self.display_list,
6647            &mut self.icc_cache,
6648        )
6649    }
6650
6651    // === Pattern operators ===
6652
6653    fn handle_pattern_fill(&mut self) -> Result<(), PdfError> {
6654        let name = self
6655            .operand_stack
6656            .last()
6657            .and_then(|o| o.as_name())
6658            .ok_or(PdfError::Other("pattern: expected name".into()))?
6659            .to_vec();
6660
6661        // For uncolored patterns (PaintType 2), the scn operands include
6662        // underlying color components before the pattern name. Extract them
6663        // by resolving the underlying color space from the Pattern CS definition.
6664        self.extract_pattern_underlying_color(false)?;
6665
6666        // Check PatternType before resolving — Type 2 (shading) needs different handling
6667        let pattern_dict = self
6668            .resolve_resource_subdict(b"Pattern")
6669            .ok_or(PdfError::Other("no Pattern resources".into()))?;
6670        let pat_ref = pattern_dict.get(&name).ok_or_else(|| {
6671            PdfError::Other(format!(
6672                "Pattern /{} not found",
6673                String::from_utf8_lossy(&name)
6674            ))
6675        })?;
6676        let pat_obj = self.resolver.deref(pat_ref)?;
6677        let pat_dict = pat_obj
6678            .as_dict()
6679            .ok_or(PdfError::Other("Pattern is not a dict".into()))?;
6680        let pattern_type = pat_dict.get_int(b"PatternType").unwrap_or(1) as i32;
6681
6682        if pattern_type == 2 {
6683            let shading_dl = self.resolve_shading_pattern(pat_dict)?;
6684            self.gstate.fill_pattern = None;
6685            self.gstate.fill_shading_pattern = Some(Box::new(ShadingPatternDL(shading_dl)));
6686        } else {
6687            let pattern = self.resolve_pattern(&name)?;
6688            self.gstate.fill_shading_pattern = None;
6689            self.gstate.fill_pattern = Some(pattern);
6690        }
6691        Ok(())
6692    }
6693
6694    fn handle_pattern_stroke(&mut self) -> Result<(), PdfError> {
6695        let name = self
6696            .operand_stack
6697            .last()
6698            .and_then(|o| o.as_name())
6699            .ok_or(PdfError::Other("pattern: expected name".into()))?
6700            .to_vec();
6701
6702        // Extract underlying color components for uncolored patterns
6703        self.extract_pattern_underlying_color(true)?;
6704
6705        // Check PatternType before resolving — Type 2 (shading) needs different handling
6706        let pattern_dict = self
6707            .resolve_resource_subdict(b"Pattern")
6708            .ok_or(PdfError::Other("no Pattern resources".into()))?;
6709        let pat_ref = pattern_dict.get(&name).ok_or_else(|| {
6710            PdfError::Other(format!(
6711                "Pattern /{} not found",
6712                String::from_utf8_lossy(&name)
6713            ))
6714        })?;
6715        let pat_obj = self.resolver.deref(pat_ref)?;
6716        let pat_dict = pat_obj
6717            .as_dict()
6718            .ok_or(PdfError::Other("Pattern is not a dict".into()))?;
6719        let pattern_type = pat_dict.get_int(b"PatternType").unwrap_or(1) as i32;
6720
6721        if pattern_type == 2 {
6722            let shading_dl = self.resolve_shading_pattern(pat_dict)?;
6723            self.gstate.stroke_pattern = None;
6724            self.gstate.stroke_shading_pattern = Some(Box::new(ShadingPatternDL(shading_dl)));
6725        } else {
6726            let pattern = self.resolve_pattern(&name)?;
6727            self.gstate.stroke_shading_pattern = None;
6728            self.gstate.stroke_pattern = Some(pattern);
6729        }
6730        Ok(())
6731    }
6732
6733    /// Extract underlying color components from the operand stack for Pattern
6734    /// color spaces with an underlying CS (e.g., `[/Pattern /DeviceRGB]`).
6735    /// For `scn` with an uncolored pattern, the operands are `c1 c2 ... /PatName`
6736    /// where c1..cn are the underlying color components.
6737    fn extract_pattern_underlying_color(&mut self, is_stroke: bool) -> Result<(), PdfError> {
6738        // Get the color space reference (fill or stroke)
6739        let cs_ref = if is_stroke {
6740            &self.gstate.stroke_color_space
6741        } else {
6742            &self.gstate.fill_color_space
6743        };
6744        let cs_name = match cs_ref {
6745            ColorSpaceRef::Named(n) => n.clone(),
6746            _ => return Ok(()),
6747        };
6748
6749        // Look up the Pattern CS definition in resources to find the underlying CS.
6750        // Use the cached cs_index (built by resolve_cs_cached) for fast lookup,
6751        // falling back to the raw ColorSpace resource dict.
6752        let cs_obj_opt: Option<crate::objects::PdfObj> = self
6753            .cs_index
6754            .as_ref()
6755            .and_then(|idx| idx.get(cs_name.as_slice()).cloned())
6756            .or_else(|| {
6757                let cs_dict = self
6758                    .resources
6759                    .get(b"ColorSpace")
6760                    .and_then(|obj| match obj {
6761                        PdfObj::Dict(_) => Some(obj.as_dict().unwrap().clone()),
6762                        PdfObj::Ref(n, g) => self.resolver.resolve(*n, *g).ok()?.as_dict().cloned(),
6763                        _ => None,
6764                    })?;
6765                cs_dict.get(&cs_name).cloned()
6766            });
6767        let cs_obj = match cs_obj_opt {
6768            Some(obj) => obj.clone(),
6769            None => return Ok(()),
6770        };
6771        let cs_resolved = self.resolver.deref(&cs_obj)?;
6772        let arr = match &cs_resolved {
6773            PdfObj::Array(a) if a.len() >= 2 => a,
6774            _ => return Ok(()),
6775        };
6776        // Must be [/Pattern <underlying_cs>]
6777        if arr[0].as_name() != Some(b"Pattern") {
6778            return Ok(());
6779        }
6780        // Resolve the underlying color space
6781        let underlying_cs = color_space::resolve_color_space_obj(&arr[1], self.resolver)?;
6782        let n = underlying_cs.num_components();
6783        if n == 0 {
6784            return Ok(());
6785        }
6786
6787        // The operand stack has: [... c1 c2 ... cn /PatName]
6788        // The pattern name is at the end; color components are before it.
6789        let stack_len = self.operand_stack.len();
6790        if stack_len < n + 1 {
6791            return Ok(()); // not enough operands
6792        }
6793        // Read n components from positions (stack_len - 1 - n) .. (stack_len - 1)
6794        let mut nums = Vec::with_capacity(n);
6795        let base = stack_len - 1 - n;
6796        for i in 0..n {
6797            nums.push(self.operand_stack[base + i].as_f64().unwrap_or(0.0));
6798        }
6799        let intent = self.gstate.rendering_intent;
6800        let color = color_space::components_to_device_color_icc_with_intent(
6801            &underlying_cs,
6802            &nums,
6803            Some(&mut self.icc_cache),
6804            intent,
6805        );
6806        if is_stroke {
6807            self.gstate.stroke_color = color;
6808        } else {
6809            self.gstate.fill_color = color;
6810        }
6811        Ok(())
6812    }
6813
6814    fn resolve_pattern(&mut self, name: &[u8]) -> Result<TilingPattern, PdfError> {
6815        let pattern_dict = self
6816            .resolve_resource_subdict(b"Pattern")
6817            .ok_or(PdfError::Other("no Pattern resources".into()))?;
6818        let pat_ref = pattern_dict.get(name).ok_or_else(|| {
6819            PdfError::Other(format!(
6820                "Pattern /{} not found",
6821                String::from_utf8_lossy(name)
6822            ))
6823        })?;
6824
6825        // Cache tiling patterns by indirect reference — ensures the same
6826        // pattern stream is interpreted only once (with the first caller's
6827        // graphics state), matching GhostScript's behaviour.
6828        if let PdfObj::Ref(obj_num, gen_num) = pat_ref {
6829            if let Some(cached) = self.pattern_cache.get(&(*obj_num, *gen_num)) {
6830                return Ok(cached.clone());
6831            }
6832        }
6833
6834        let pat_ref_clone = pat_ref.clone();
6835        let pat_obj = self.resolver.deref(pat_ref)?;
6836        let pat_dict = pat_obj
6837            .as_dict()
6838            .ok_or(PdfError::Other("Pattern is not a dict".into()))?;
6839
6840        let pattern_type = pat_dict.get_int(b"PatternType").unwrap_or(1) as i32;
6841
6842        let result = match pattern_type {
6843            1 => self.resolve_tiling_pattern(&pat_ref_clone, pat_dict),
6844            _ => Err(PdfError::Other(format!(
6845                "Unsupported PatternType {pattern_type}"
6846            ))),
6847        }?;
6848
6849        if let PdfObj::Ref(obj_num, gen_num) = pat_ref {
6850            self.pattern_cache
6851                .insert((*obj_num, *gen_num), result.clone());
6852        }
6853
6854        Ok(result)
6855    }
6856
6857    fn resolve_tiling_pattern(
6858        &mut self,
6859        pat_obj: &PdfObj,
6860        pat_dict: &PdfDict,
6861    ) -> Result<TilingPattern, PdfError> {
6862        if self.depth >= MAX_CONTENT_NESTING {
6863            return Err(PdfError::Other("pattern recursion limit".into()));
6864        }
6865        let paint_type = pat_dict.get_int(b"PaintType").unwrap_or(1) as i32;
6866
6867        let bbox = deref_num_array(self.resolver, pat_dict, b"BBox")
6868            .map(|v| {
6869                if v.len() >= 4 {
6870                    [v[0], v[1], v[2], v[3]]
6871                } else {
6872                    [0.0, 0.0, 1.0, 1.0]
6873                }
6874            })
6875            .unwrap_or([0.0, 0.0, 1.0, 1.0]);
6876
6877        let x_step = pat_dict.get_f64(b"XStep").unwrap_or(bbox[2] - bbox[0]);
6878        let y_step = pat_dict.get_f64(b"YStep").unwrap_or(bbox[3] - bbox[1]);
6879
6880        let pattern_matrix = deref_num_array(self.resolver, pat_dict, b"Matrix")
6881            .map(|v| {
6882                if v.len() >= 6 {
6883                    Matrix::new(v[0], v[1], v[2], v[3], v[4], v[5])
6884                } else {
6885                    Matrix::identity()
6886                }
6887            })
6888            .unwrap_or_else(Matrix::identity);
6889
6890        let pattern_resources = if let Some(res_ref) = pat_dict.get(b"Resources") {
6891            match self.resolver.deref(res_ref)? {
6892                PdfObj::Dict(d) => d,
6893                _ => self.resources.clone(),
6894            }
6895        } else {
6896            self.resources.clone()
6897        };
6898
6899        let pattern_data = self.resolver.stream_data_from_obj(pat_obj)?;
6900
6901        // Compute the combined pattern matrix (pattern space → device space).
6902        // PDF pattern Matrix maps pattern space → the default coordinate system
6903        // of the parent content stream. Use content_stream_ctm so patterns inside
6904        // Form XObjects include the form's coordinate transform.
6905        let combined_matrix = self.content_stream_ctm.concat(&pattern_matrix);
6906
6907        // Interpret pattern content stream with identity CTM, keeping tile
6908        // elements in pattern space.  The combined_matrix is stored in the
6909        // TilingPattern and applied per-element by the renderer (same approach
6910        // as PostScript op_makepattern).
6911        self.gstate_stack.push(self.gstate.clone());
6912        let saved_resources = std::mem::replace(&mut self.resources, pattern_resources);
6913        let saved_display_list = std::mem::take(&mut self.display_list);
6914        let saved_content_stream_ctm = self.content_stream_ctm;
6915        let saved_path = std::mem::take(&mut self.current_path);
6916        let saved_point = self.current_point.take();
6917        let saved_subpath = self.subpath_start.take();
6918        let saved_mc_stack = std::mem::take(&mut self.mc_stack);
6919
6920        self.gstate.ctm = Matrix::identity();
6921        self.content_stream_ctm = Matrix::identity();
6922        self.gstate.clip_path = None;
6923        self.gstate.clip_path_version = 0;
6924        self.gstate.clip_stack.clear();
6925        // Clear parent patterns to prevent infinite recursion if the pattern
6926        // stream references the same pattern resource.
6927        self.gstate.fill_pattern = None;
6928        self.gstate.stroke_pattern = None;
6929        self.gstate.fill_shading_pattern = None;
6930        self.gstate.stroke_shading_pattern = None;
6931        // Reset text rendering mode so pattern tiles don't inherit
6932        // fill+stroke or other modes from the parent content stream.
6933        self.gstate.text_rendering_mode = 0;
6934
6935        self.depth += 1;
6936        let _ = self.interpret_stream(&pattern_data);
6937        self.depth -= 1;
6938
6939        // Flush any pending soft mask scope from the pattern stream
6940        self.flush_soft_mask();
6941
6942        let tile_display_list = std::mem::replace(&mut self.display_list, saved_display_list);
6943        self.content_stream_ctm = saved_content_stream_ctm;
6944        self.resources = saved_resources;
6945        self.current_path = saved_path;
6946        self.current_point = saved_point;
6947        self.subpath_start = saved_subpath;
6948        self.mc_stack = saved_mc_stack;
6949        if let Some(saved) = self.gstate_stack.pop() {
6950            self.gstate = saved;
6951        }
6952
6953        Ok(TilingPattern {
6954            tile: tile_display_list,
6955            bbox,
6956            x_step,
6957            y_step,
6958            pattern_matrix: combined_matrix,
6959            paint_type,
6960            pattern_id: 0,
6961            flip_tile_y: false,
6962        })
6963    }
6964
6965    /// Resolve a PatternType 2 (shading pattern) by rendering the shading into
6966    /// a display list. The caller stores this and emits it at fill time,
6967    /// clipped to the fill path.
6968    fn resolve_shading_pattern(&mut self, pat_dict: &PdfDict) -> Result<DisplayList, PdfError> {
6969        let sh_ref = pat_dict
6970            .get(b"Shading")
6971            .ok_or(PdfError::Other("shading pattern missing /Shading".into()))?;
6972        let sh_ref_clone = sh_ref.clone();
6973        let sh_obj = self.resolver.deref(sh_ref)?;
6974        let sh_dict = sh_obj
6975            .as_dict()
6976            .ok_or(PdfError::Other("Shading is not a dict".into()))?;
6977
6978        let pattern_matrix = deref_num_array(self.resolver, pat_dict, b"Matrix")
6979            .map(|v| {
6980                if v.len() >= 6 {
6981                    Matrix::new(v[0], v[1], v[2], v[3], v[4], v[5])
6982                } else {
6983                    Matrix::identity()
6984                }
6985            })
6986            .unwrap_or_else(Matrix::identity);
6987
6988        // Render the shading into a temporary display list with pattern matrix
6989        // applied to the CTM so coordinates are in device space.
6990        // Use content_stream_ctm so that patterns inside Form XObjects include
6991        // the form's coordinate transform.
6992        //
6993        // Clear overprint while building the shading display list. The pattern
6994        // is resolved at `scn`/`SCN` time, but overprint should come from the
6995        // graphics state at paint time (when Tj/f/S executes). Capturing the
6996        // resolution-time overprint bakes a stale flag into the shading elements
6997        // — if the caller applies a different ExtGState between `scn` and the
6998        // paint operator, the shading would carry the wrong overprint state.
6999        // Setting overprint=false here is safe because paint-time code wraps
7000        // shading patterns in isolated groups, so overprint compositing does
7001        // not cross the group boundary.
7002        let combined_matrix = self.content_stream_ctm.concat(&pattern_matrix);
7003        let saved_ctm = self.gstate.ctm;
7004        let saved_overprint = self.gstate.overprint;
7005        let saved_overprint_stroke = self.gstate.overprint_stroke;
7006        self.gstate.ctm = combined_matrix;
7007        self.gstate.overprint = false;
7008        self.gstate.overprint_stroke = false;
7009
7010        let mut shading_dl = DisplayList::new();
7011        let result = crate::resources::shading::handle_shading(
7012            &sh_ref_clone,
7013            sh_dict,
7014            &self.gstate,
7015            self.resolver,
7016            &mut shading_dl,
7017            &mut self.icc_cache,
7018        );
7019        self.gstate.ctm = saved_ctm;
7020        self.gstate.overprint = saved_overprint;
7021        self.gstate.overprint_stroke = saved_overprint_stroke;
7022        result?;
7023        Ok(shading_dl)
7024    }
7025}
7026
7027/// Test whether two `[xmin, ymin, xmax, ymax]` rectangles overlap by at
7028/// least `min_extent` device units in BOTH dimensions. The `[f64; 4]`
7029/// slots are accepted in either ordering (we min/max the pair of x and y
7030/// components before testing).
7031///
7032/// Used by `flush_soft_mask` to discriminate "mask form is meaningfully
7033/// covering the content" (≥ min_extent in both axes) from "mask form is
7034/// only edge-touching the content" (< min_extent in at least one axis).
7035/// The latter case is treated as a no-op mask, matching the historical
7036/// Ghostscript behavior on PDFs like 5795.pdf where the mask form lives
7037/// in off-page coordinates and barely grazes the visible content.
7038fn bboxes_overlap_substantially(a: &[f64; 4], b: &[f64; 4], min_extent: f64) -> bool {
7039    let (ax0, ay0, ax1, ay1) = (
7040        a[0].min(a[2]),
7041        a[1].min(a[3]),
7042        a[0].max(a[2]),
7043        a[1].max(a[3]),
7044    );
7045    let (bx0, by0, bx1, by1) = (
7046        b[0].min(b[2]),
7047        b[1].min(b[3]),
7048        b[0].max(b[2]),
7049        b[1].max(b[3]),
7050    );
7051    let overlap_w = (ax1.min(bx1) - ax0.max(bx0)).max(0.0);
7052    let overlap_h = (ay1.min(by1) - ay0.max(by0)).max(0.0);
7053    overlap_w >= min_extent && overlap_h >= min_extent
7054}
7055
7056fn path_device_bbox(path: &PsPath) -> [f64; 4] {
7057    let mut x_min = f64::INFINITY;
7058    let mut y_min = f64::INFINITY;
7059    let mut x_max = f64::NEG_INFINITY;
7060    let mut y_max = f64::NEG_INFINITY;
7061    let mut update = |x: f64, y: f64| {
7062        x_min = x_min.min(x);
7063        y_min = y_min.min(y);
7064        x_max = x_max.max(x);
7065        y_max = y_max.max(y);
7066    };
7067    for seg in &path.segments {
7068        match seg {
7069            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => update(*x, *y),
7070            PathSegment::CurveTo {
7071                x1,
7072                y1,
7073                x2,
7074                y2,
7075                x3,
7076                y3,
7077            } => {
7078                update(*x1, *y1);
7079                update(*x2, *y2);
7080                update(*x3, *y3);
7081            }
7082            PathSegment::ClosePath => {}
7083        }
7084    }
7085    [x_min, y_min, x_max, y_max]
7086}
7087
7088/// Convert a color space name to a ColorSpaceRef.
7089fn name_to_cs_ref(name: &[u8]) -> ColorSpaceRef {
7090    match name {
7091        b"DeviceGray" | b"G" => ColorSpaceRef::DeviceGray,
7092        b"DeviceRGB" | b"RGB" => ColorSpaceRef::DeviceRGB,
7093        b"DeviceCMYK" | b"CMYK" => ColorSpaceRef::DeviceCMYK,
7094        _ => ColorSpaceRef::Named(name.to_vec()),
7095    }
7096}
7097
7098/// Expand abbreviated inline image key names.
7099fn expand_inline_key(key: &[u8]) -> Vec<u8> {
7100    match key {
7101        b"BPC" => b"BitsPerComponent".to_vec(),
7102        b"CS" => b"ColorSpace".to_vec(),
7103        b"D" => b"Decode".to_vec(),
7104        b"DP" => b"DecodeParms".to_vec(),
7105        b"F" => b"Filter".to_vec(),
7106        b"H" => b"Height".to_vec(),
7107        b"IM" => b"ImageMask".to_vec(),
7108        b"I" => b"Interpolate".to_vec(),
7109        b"W" => b"Width".to_vec(),
7110        _ => key.to_vec(),
7111    }
7112}
7113
7114/// Expand abbreviated inline image value names.
7115fn expand_inline_value(name: &[u8]) -> Vec<u8> {
7116    match name {
7117        b"G" => b"DeviceGray".to_vec(),
7118        b"RGB" => b"DeviceRGB".to_vec(),
7119        b"CMYK" => b"DeviceCMYK".to_vec(),
7120        b"I" => b"Indexed".to_vec(),
7121        b"AHx" => b"ASCIIHexDecode".to_vec(),
7122        b"A85" => b"ASCII85Decode".to_vec(),
7123        b"LZW" => b"LZWDecode".to_vec(),
7124        b"Fl" => b"FlateDecode".to_vec(),
7125        b"RL" => b"RunLengthDecode".to_vec(),
7126        b"CCF" => b"CCITTFaxDecode".to_vec(),
7127        b"DCT" => b"DCTDecode".to_vec(),
7128        _ => name.to_vec(),
7129    }
7130}
7131
7132/// Bilinear upsample of image data to target dimensions.
7133/// Used when an explicit mask is higher resolution than the image (MRC PDFs).
7134fn bilinear_upsample_image(
7135    data: &[u8],
7136    sw: u32,
7137    sh: u32,
7138    dw: u32,
7139    dh: u32,
7140    cs: &ImageColorSpace,
7141) -> Vec<u8> {
7142    let n = cs.num_components() as usize;
7143    if n == 0 || sw == 0 || sh == 0 || dw == 0 || dh == 0 {
7144        return data.to_vec();
7145    }
7146    let src_stride = sw as usize * n;
7147    let dst_stride = dw as usize * n;
7148    let mut out = vec![0u8; dst_stride * dh as usize];
7149
7150    for dy in 0..dh as usize {
7151        let sy = (dy as f32 + 0.5) * sh as f32 / dh as f32 - 0.5;
7152        let sy0 = (sy.floor() as i32).clamp(0, sh as i32 - 1) as usize;
7153        let sy1 = (sy0 + 1).min(sh as usize - 1);
7154        let fy = sy - sy0 as f32;
7155
7156        for dx in 0..dw as usize {
7157            let sx = (dx as f32 + 0.5) * sw as f32 / dw as f32 - 0.5;
7158            let sx0 = (sx.floor() as i32).clamp(0, sw as i32 - 1) as usize;
7159            let sx1 = (sx0 + 1).min(sw as usize - 1);
7160            let fx = sx - sx0 as f32;
7161
7162            let w00 = (1.0 - fx) * (1.0 - fy);
7163            let w10 = fx * (1.0 - fy);
7164            let w01 = (1.0 - fx) * fy;
7165            let w11 = fx * fy;
7166
7167            let i00 = sy0 * src_stride + sx0 * n;
7168            let i10 = sy0 * src_stride + sx1 * n;
7169            let i01 = sy1 * src_stride + sx0 * n;
7170            let i11 = sy1 * src_stride + sx1 * n;
7171
7172            let di = dy * dst_stride + dx * n;
7173            for c in 0..n {
7174                let v = data[i00 + c] as f32 * w00
7175                    + data[i10 + c] as f32 * w10
7176                    + data[i01 + c] as f32 * w01
7177                    + data[i11 + c] as f32 * w11;
7178                out[di + c] = (v + 0.5).clamp(0.0, 255.0) as u8;
7179            }
7180        }
7181    }
7182    out
7183}
7184
7185/// Expand image sample data from arbitrary BPC to 8-bit.
7186/// Merge image sample data with an SMask alpha channel into RGBA.
7187fn merge_rgb_with_smask(
7188    image_data: &[u8],
7189    smask_data: &[u8],
7190    color_space: &ImageColorSpace,
7191    width: u32,
7192    height: u32,
7193    icc: Option<&stet_graphics::icc::IccCache>,
7194) -> Vec<u8> {
7195    // For Indexed images, expand palette indices to RGB first
7196    if let ImageColorSpace::Indexed {
7197        base,
7198        hival,
7199        lookup,
7200    } = color_space
7201    {
7202        let n_base = base.num_components() as usize;
7203        let n_pixels = (width * height) as usize;
7204        let mut expanded = vec![0u8; n_pixels.saturating_mul(n_base)];
7205        for i in 0..n_pixels {
7206            let idx = image_data.get(i).copied().unwrap_or(0) as usize;
7207            let idx = idx.min(*hival as usize);
7208            let offset = idx * n_base;
7209            for c in 0..n_base {
7210                expanded[i * n_base + c] = lookup.get(offset + c).copied().unwrap_or(0);
7211            }
7212        }
7213        return merge_rgb_with_smask(&expanded, smask_data, base, width, height, icc);
7214    }
7215
7216    // For Separation/DeviceN, convert through tint table to alternate space first
7217    if let ImageColorSpace::Separation {
7218        alt_space,
7219        tint_table,
7220        ..
7221    } = color_space
7222    {
7223        let n_pixels = (width * height) as usize;
7224        let no = tint_table.num_outputs as usize;
7225        let mut expanded = vec![0u8; n_pixels.saturating_mul(no)];
7226        let mut alt_comps = vec![0.0f32; no];
7227        for i in 0..n_pixels {
7228            let tint = image_data.get(i).copied().unwrap_or(0) as f32 / 255.0;
7229            tint_table.lookup_1d(tint, &mut alt_comps);
7230            for c in 0..no {
7231                expanded[i * no + c] = (alt_comps[c].clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
7232            }
7233        }
7234        return merge_rgb_with_smask(&expanded, smask_data, alt_space, width, height, icc);
7235    }
7236    if let ImageColorSpace::DeviceN {
7237        alt_space,
7238        tint_table,
7239        ..
7240    } = color_space
7241    {
7242        let ni = tint_table.num_inputs as usize;
7243        let no = tint_table.num_outputs as usize;
7244        let n_pixels = (width * height) as usize;
7245        let mut expanded = vec![0u8; n_pixels.saturating_mul(no)];
7246        let mut inputs = vec![0.0f32; ni];
7247        let mut alt_comps = vec![0.0f32; no];
7248        for i in 0..n_pixels {
7249            let si = i * ni;
7250            for (c, inp) in inputs.iter_mut().enumerate() {
7251                *inp = image_data.get(si + c).copied().unwrap_or(0) as f32 / 255.0;
7252            }
7253            tint_table.lookup_nd(&inputs, &mut alt_comps);
7254            for c in 0..no {
7255                expanded[i * no + c] = (alt_comps[c].clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
7256            }
7257        }
7258        return merge_rgb_with_smask(&expanded, smask_data, alt_space, width, height, icc);
7259    }
7260
7261    let n_pixels = (width * height) as usize;
7262    let mut rgba = vec![255u8; n_pixels * 4];
7263    let n_comps = color_space.num_components();
7264
7265    // For CMYK data, try ICC bulk conversion first (matches samples_to_rgba quality)
7266    if n_comps == 4 {
7267        if let Some(cache) = icc {
7268            if let Some(cmyk_hash) = cache.default_cmyk_hash() {
7269                let cmyk_data = if image_data.len() >= n_pixels * 4 {
7270                    &image_data[..n_pixels * 4]
7271                } else {
7272                    image_data
7273                };
7274                if let Some(rgb) = cache.convert_image_8bit(cmyk_hash, cmyk_data, n_pixels) {
7275                    for i in 0..n_pixels {
7276                        let alpha = smask_data.get(i).copied().unwrap_or(255);
7277                        let dst = i * 4;
7278                        let (r, g, b) = (rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2]);
7279                        if alpha == 255 {
7280                            rgba[dst] = r;
7281                            rgba[dst + 1] = g;
7282                            rgba[dst + 2] = b;
7283                            rgba[dst + 3] = 255;
7284                        } else if alpha == 0 {
7285                            // rgba already zeroed by default 255, need to zero
7286                            rgba[dst] = 0;
7287                            rgba[dst + 1] = 0;
7288                            rgba[dst + 2] = 0;
7289                            rgba[dst + 3] = 0;
7290                        } else {
7291                            let a = alpha as u16;
7292                            rgba[dst] = ((r as u16 * a + 127) / 255) as u8;
7293                            rgba[dst + 1] = ((g as u16 * a + 127) / 255) as u8;
7294                            rgba[dst + 2] = ((b as u16 * a + 127) / 255) as u8;
7295                            rgba[dst + 3] = alpha;
7296                        }
7297                    }
7298                    return rgba;
7299                }
7300            }
7301        }
7302    }
7303
7304    for i in 0..n_pixels {
7305        let alpha = smask_data.get(i).copied().unwrap_or(255);
7306        let dst = i * 4;
7307        match n_comps {
7308            3 => {
7309                // RGB
7310                let src = i * 3;
7311                rgba[dst] = image_data.get(src).copied().unwrap_or(0);
7312                rgba[dst + 1] = image_data.get(src + 1).copied().unwrap_or(0);
7313                rgba[dst + 2] = image_data.get(src + 2).copied().unwrap_or(0);
7314            }
7315            1 => {
7316                // Gray
7317                let g = image_data.get(i).copied().unwrap_or(0);
7318                rgba[dst] = g;
7319                rgba[dst + 1] = g;
7320                rgba[dst + 2] = g;
7321            }
7322            4 => {
7323                // CMYK → RGB (PLRM fallback when ICC not available)
7324                let src = i * 4;
7325                let c = image_data.get(src).copied().unwrap_or(0) as f64 / 255.0;
7326                let m = image_data.get(src + 1).copied().unwrap_or(0) as f64 / 255.0;
7327                let y = image_data.get(src + 2).copied().unwrap_or(0) as f64 / 255.0;
7328                let k = image_data.get(src + 3).copied().unwrap_or(0) as f64 / 255.0;
7329                rgba[dst] = ((1.0 - c) * (1.0 - k) * 255.0 + 0.5) as u8;
7330                rgba[dst + 1] = ((1.0 - m) * (1.0 - k) * 255.0 + 0.5) as u8;
7331                rgba[dst + 2] = ((1.0 - y) * (1.0 - k) * 255.0 + 0.5) as u8;
7332            }
7333            _ => {
7334                // Unknown — treat as black
7335            }
7336        }
7337        // Premultiply alpha (tiny-skia expects premultiplied RGBA)
7338        if alpha == 255 {
7339            rgba[dst + 3] = 255;
7340        } else if alpha == 0 {
7341            rgba[dst] = 0;
7342            rgba[dst + 1] = 0;
7343            rgba[dst + 2] = 0;
7344            rgba[dst + 3] = 0;
7345        } else {
7346            let a = alpha as u16;
7347            rgba[dst] = ((rgba[dst] as u16 * a + 127) / 255) as u8;
7348            rgba[dst + 1] = ((rgba[dst + 1] as u16 * a + 127) / 255) as u8;
7349            rgba[dst + 2] = ((rgba[dst + 2] as u16 * a + 127) / 255) as u8;
7350            rgba[dst + 3] = alpha;
7351        }
7352    }
7353    rgba
7354}
7355
7356fn expand_bits_to_bytes(
7357    data: &[u8],
7358    bpc: u32,
7359    width: u32,
7360    height: u32,
7361    components: u32,
7362    is_indexed: bool,
7363) -> Vec<u8> {
7364    if bpc == 0 || bpc == 8 {
7365        return data.to_vec();
7366    }
7367
7368    // `bpc` is validated at every image entry point, but this is a free
7369    // function that could be reached from a future caller: a shift of 32 or
7370    // more is a panic in debug builds and a masked no-op in release.
7371    if bpc >= 32 {
7372        return Vec::new();
7373    }
7374    let max_val = ((1u32 << bpc) - 1) as f64;
7375    let samples_per_row = width * components.max(1);
7376    // Reserve in `usize`, not `u32`: the three-way product overflows a `u32`
7377    // well before the two-way `width * height` does, and a wrapped capacity
7378    // silently under-reserves rather than failing.
7379    let capacity = (width as usize)
7380        .saturating_mul(height as usize)
7381        .saturating_mul(components.max(1) as usize);
7382    let mut result = Vec::with_capacity(capacity);
7383
7384    for row in 0..height {
7385        let row_bit_offset = row as usize * ((samples_per_row * bpc).div_ceil(8) * 8) as usize;
7386        for col in 0..samples_per_row {
7387            let bit_offset = row_bit_offset + (col * bpc) as usize;
7388            let byte_offset = bit_offset / 8;
7389            let bit_shift = bit_offset % 8;
7390
7391            if byte_offset >= data.len() {
7392                result.push(0);
7393                continue;
7394            }
7395
7396            // Extract bpc bits
7397            let mut val = 0u32;
7398            let mut bits_remaining = bpc;
7399            let mut cur_byte = byte_offset;
7400            let mut cur_bit = bit_shift;
7401
7402            while bits_remaining > 0 && cur_byte < data.len() {
7403                let available = 8 - cur_bit as u32;
7404                let take = bits_remaining.min(available);
7405                let shift = available - take;
7406                let mask = ((1u32 << take) - 1) << shift;
7407                val = (val << take) | ((data[cur_byte] as u32 & mask) >> shift);
7408                bits_remaining -= take;
7409                cur_bit = 0;
7410                cur_byte += 1;
7411            }
7412
7413            // For Indexed color spaces, values are palette indices — keep raw.
7414            // For other color spaces, scale to 0-255.
7415            if is_indexed {
7416                result.push(val as u8);
7417            } else {
7418                result.push((val as f64 / max_val * 255.0 + 0.5) as u8);
7419            }
7420        }
7421    }
7422
7423    result
7424}
7425
7426/// Convert a PDF blend mode name to a numeric code.
7427fn blend_mode_from_name(name: &[u8]) -> u8 {
7428    match name {
7429        b"Normal" | b"Compatible" => 0,
7430        b"Multiply" => 1,
7431        b"Screen" => 2,
7432        b"Overlay" => 3,
7433        b"Darken" => 4,
7434        b"Lighten" => 5,
7435        b"ColorDodge" => 6,
7436        b"ColorBurn" => 7,
7437        b"HardLight" => 8,
7438        b"SoftLight" => 9,
7439        b"Difference" => 10,
7440        b"Exclusion" => 11,
7441        b"Hue" => 12,
7442        b"Saturation" => 13,
7443        b"Color" => 14,
7444        b"Luminosity" => 15,
7445        _ => 0,
7446    }
7447}
7448
7449fn is_whitespace_byte(b: u8) -> bool {
7450    matches!(b, b' ' | b'\t' | b'\r' | b'\n' | 0x0C | 0x00)
7451}
7452
7453fn is_delimiter_or_ws(b: u8) -> bool {
7454    is_whitespace_byte(b)
7455        || matches!(
7456            b,
7457            b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
7458        )
7459}
7460
7461/// Evaluate a PDF function at 256 evenly-spaced points in [0,1] to build a transfer table.
7462fn sample_transfer_function(func: &crate::resources::function::PdfFunction) -> Vec<f64> {
7463    (0..256)
7464        .map(|i| {
7465            let t = i as f64 / 255.0;
7466            let result = func.evaluate(&[t]);
7467            result.first().copied().unwrap_or(t).clamp(0.0, 1.0)
7468        })
7469        .collect()
7470}
7471
7472/// Apply transfer functions to RGB image pixel data (in-place).
7473///
7474/// `data` is interleaved RGB (3 bytes per pixel) or RGBA (4 bytes per pixel).
7475/// Transfer tables are 256-sample [0,1]→[0,1] lookup tables.
7476fn apply_transfer_to_image(
7477    data: &mut [u8],
7478    transfer: &stet_graphics::device::TransferState,
7479    components: usize,
7480) {
7481    // Build 256-entry u8 lookup tables for each RGB channel
7482    let (r_table, g_table, b_table) = if let Some(ref color) = transfer.color {
7483        // Per-component transfer: [R, G, B, Gray]
7484        let r = build_u8_lut(color[0].as_ref().map(|v| &v[..]));
7485        let g = build_u8_lut(color[1].as_ref().map(|v| &v[..]));
7486        let b = build_u8_lut(color[2].as_ref().map(|v| &v[..]));
7487        (r, g, b)
7488    } else if let Some(ref gray) = transfer.gray {
7489        // Single function applied to all channels
7490        let lut = build_u8_lut(Some(&gray[..]));
7491        (lut, lut, lut)
7492    } else {
7493        return; // Identity — nothing to do
7494    };
7495
7496    // Apply LUT per channel
7497    let stride = components;
7498    for pixel in data.chunks_exact_mut(stride) {
7499        if pixel.len() >= 3 {
7500            pixel[0] = r_table[pixel[0] as usize];
7501            pixel[1] = g_table[pixel[1] as usize];
7502            pixel[2] = b_table[pixel[2] as usize];
7503        }
7504    }
7505}
7506
7507/// Apply transfer functions to a DeviceColor (fill/stroke).
7508fn apply_transfer_to_color(
7509    color: &DeviceColor,
7510    transfer: &stet_graphics::device::TransferState,
7511) -> DeviceColor {
7512    if let Some(ref color_tables) = transfer.color {
7513        // Per-component transfer: [R, G, B, Gray]
7514        let r = apply_transfer_component(color.r, color_tables[0].as_ref().map(|v| &v[..]));
7515        let g = apply_transfer_component(color.g, color_tables[1].as_ref().map(|v| &v[..]));
7516        let b = apply_transfer_component(color.b, color_tables[2].as_ref().map(|v| &v[..]));
7517        DeviceColor::from_rgb(r, g, b)
7518    } else if let Some(ref gray) = transfer.gray {
7519        let r = apply_transfer_component(color.r, Some(&gray[..]));
7520        let g = apply_transfer_component(color.g, Some(&gray[..]));
7521        let b = apply_transfer_component(color.b, Some(&gray[..]));
7522        DeviceColor::from_rgb(r, g, b)
7523    } else {
7524        color.clone()
7525    }
7526}
7527
7528/// Look up a single f64 component [0,1] through a transfer table.
7529fn apply_transfer_component(value: f64, table: Option<&[f64]>) -> f64 {
7530    match table {
7531        None => value,
7532        Some(t) if t.len() != 256 => value,
7533        Some(t) => {
7534            let idx = (value * 255.0).clamp(0.0, 255.0);
7535            let lo = idx.floor() as usize;
7536            let hi = (lo + 1).min(255);
7537            let frac = idx - lo as f64;
7538            let v0 = t[lo];
7539            let v1 = t[hi];
7540            (v0 + frac * (v1 - v0)).clamp(0.0, 1.0)
7541        }
7542    }
7543}
7544
7545/// Build a 256-entry u8 lookup table from a transfer table.
7546fn build_u8_lut(table: Option<&[f64]>) -> [u8; 256] {
7547    let mut lut = [0u8; 256];
7548    match table {
7549        Some(t) if t.len() == 256 => {
7550            for (i, v) in lut.iter_mut().enumerate() {
7551                *v = (t[i].clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
7552            }
7553        }
7554        _ => {
7555            for (i, v) in lut.iter_mut().enumerate() {
7556                *v = i as u8;
7557            }
7558        }
7559    }
7560    lut
7561}