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