Skip to main content

justpdf_render/
interpreter.rs

1use std::collections::HashMap;
2
3use justpdf_core::color::{Color as PdfColor, ColorSpace};
4use justpdf_core::content::{ContentOp, Operand, parse_content_stream};
5use justpdf_core::font::{FontInfo, ToUnicodeCMap, parse_font_info};
6use justpdf_core::image;
7use justpdf_core::object::{PdfDict, PdfObject};
8use justpdf_core::ocg::{self, OCConfig};
9use justpdf_core::page::PageInfo;
10use justpdf_core::PdfDocument;
11use tiny_skia::{FillRule, Mask, PathBuilder, Pixmap, Transform};
12
13use crate::device::PixmapDevice;
14use crate::error::{RenderError, Result};
15use crate::glyph_cache::GlyphCache;
16use crate::graphics_state::{
17    GraphicsState, LineCap, LineJoin, Matrix, PdfBlendMode, SoftMask, SoftMaskSubtype,
18};
19
20/// Resolved font for rendering.
21struct ResolvedFont {
22    info: FontInfo,
23    #[allow(dead_code)]
24    cmap: Option<ToUnicodeCMap>,
25    /// Raw embedded font data (TrueType/OpenType/CFF) for glyph outlines.
26    font_data: Option<Vec<u8>>,
27    /// CIDToGIDMap for Type0 CID fonts: maps CID → glyph ID.
28    /// None = identity mapping (CID == GID).
29    cid_to_gid_map: Option<Vec<u16>>,
30}
31
32/// The rendering interpreter: walks content stream ops and renders onto a device.
33pub struct RenderInterpreter<'a> {
34    doc: &'a PdfDocument,
35    device: &'a mut PixmapDevice,
36    state: GraphicsState,
37    state_stack: Vec<GraphicsState>,
38    fonts: HashMap<Vec<u8>, ResolvedFont>,
39    /// Transform from PDF user space to device (pixel) space.
40    page_transform: Matrix,
41    /// Current path being constructed.
42    path_builder: Option<PathBuilder>,
43    /// Form XObject recursion depth limit.
44    xobject_depth: u32,
45    /// Cache for pre-built glyph paths.
46    glyph_cache: GlyphCache,
47    /// Optional content configuration for layer visibility.
48    oc_config: Option<OCConfig>,
49    /// Marked content skip depth: when > 0, all drawing ops are skipped
50    /// until the matching EMC reduces it back to 0.
51    oc_skip_depth: u32,
52}
53
54impl<'a> RenderInterpreter<'a> {
55    pub fn new(
56        doc: &'a PdfDocument,
57        device: &'a mut PixmapDevice,
58        page_transform: Matrix,
59    ) -> Self {
60        // Load optional content config for layer visibility
61        let oc_config = ocg::read_oc_properties(doc)
62            .ok()
63            .flatten()
64            .and_then(|props| props.default_config);
65
66        Self {
67            doc,
68            device,
69            state: GraphicsState::default(),
70            state_stack: Vec::new(),
71            fonts: HashMap::new(),
72            page_transform,
73            path_builder: None,
74            xobject_depth: 0,
75            glyph_cache: GlyphCache::with_default_capacity(),
76            oc_config,
77            oc_skip_depth: 0,
78        }
79    }
80
81    /// Render a page's content streams, then annotation appearance streams.
82    pub fn render_page(&mut self, page: &PageInfo) -> Result<()> {
83        // Resolve resources and fonts (non-fatal if it fails)
84        let _ = self.resolve_page_fonts(page);
85
86        // Get content stream data
87        let content_data = self.get_page_content(page)?;
88        if !content_data.is_empty() {
89            let ops = parse_content_stream(&content_data).map_err(|e| RenderError::Core(e))?;
90            self.execute_ops(&ops, page)?;
91        }
92
93        // Render annotation appearance streams
94        let _ = self.render_annotations(page);
95
96        Ok(())
97    }
98
99    /// Render annotation appearance streams on top of page content.
100    fn render_annotations(&mut self, page: &PageInfo) -> Result<()> {
101        // Get page dict → /Annots array
102        let page_obj = self.doc.resolve(&page.page_ref)?;
103        let page_dict = match page_obj.as_dict() {
104            Some(d) => d.clone(),
105            None => return Ok(()),
106        };
107
108        let annots_arr = match page_dict.get(b"Annots") {
109            Some(PdfObject::Array(arr)) => arr.clone(),
110            Some(PdfObject::Reference(r)) => {
111                let r = r.clone();
112                match self.doc.resolve(&r)? {
113                    PdfObject::Array(arr) => arr,
114                    _ => return Ok(()),
115                }
116            }
117            _ => return Ok(()),
118        };
119
120        for item in &annots_arr {
121            let annot_dict = match item {
122                PdfObject::Reference(r) => {
123                    let r = r.clone();
124                    match self.doc.resolve(&r)? {
125                        PdfObject::Dict(d) => d,
126                        _ => continue,
127                    }
128                }
129                PdfObject::Dict(d) => d.clone(),
130                _ => continue,
131            };
132
133            // Skip hidden/no-view annotations
134            let flags = annot_dict
135                .get_i64(b"F")
136                .unwrap_or(0) as u32;
137            if flags & 0x02 != 0 || flags & 0x20 != 0 {
138                // Hidden or NoView
139                continue;
140            }
141
142            // Get appearance stream: /AP /N
143            let ap_stream = match annot_dict.get(b"AP") {
144                Some(PdfObject::Dict(ap)) => {
145                    let n_obj = match ap.get(b"N") {
146                        Some(PdfObject::Reference(r)) => {
147                            let r = r.clone();
148                            self.doc.resolve(&r)?
149                        }
150                        Some(other) => other.clone(),
151                        None => continue,
152                    };
153                    match n_obj {
154                        PdfObject::Stream { dict, data } => (dict, data),
155                        _ => continue,
156                    }
157                }
158                _ => continue,
159            };
160
161            // Require annotation rect (needed for positioning)
162            if annot_dict.get_array(b"Rect").is_none() {
163                continue;
164            }
165
166            // Save graphics state, render AP Form XObject
167            self.state_stack.push(self.state.clone());
168
169            let (ap_dict, ap_data) = ap_stream;
170            let _ = self.render_form_xobject(&ap_dict, &ap_data, page);
171
172            // Restore graphics state
173            if let Some(saved) = self.state_stack.pop() {
174                self.state = saved;
175            }
176        }
177
178        Ok(())
179    }
180
181    /// Check if an OC (Optional Content) marked content block is visible.
182    ///
183    /// BDC operands for OC are: /OC <properties>
184    /// The properties can be:
185    /// - An inline dict with /Type /OCG and we check the OCG ref
186    /// - An inline dict with /Type /OCMD and we check the membership
187    /// - A name referencing a Properties resource (indirect OCG/OCMD)
188    fn check_oc_visibility(&self, operands: &[Operand], config: &OCConfig) -> bool {
189        // The second operand is the properties dict or name
190        let props = match operands.get(1) {
191            Some(o) => o,
192            None => return true,
193        };
194
195        match props {
196            Operand::Dict(dict) => {
197                let pdf_dict = self.operand_dict_to_pdf_dict(dict);
198                self.check_oc_dict_visibility(&pdf_dict, config)
199            }
200            Operand::Name(_name) => {
201                // Name references a Properties resource — resolve it
202                // TODO: look up in page /Resources /Properties dict
203                true
204            }
205            _ => true,
206        }
207    }
208
209    /// Check visibility of an OC dictionary (OCG or OCMD).
210    fn check_oc_dict_visibility(&self, dict: &PdfDict, config: &OCConfig) -> bool {
211        let type_name = dict.get_name(b"Type").unwrap_or(b"");
212
213        if type_name == b"OCG" {
214            // Direct OCG reference — check if this OCG is visible
215            // We need the IndirectRef, but inline dicts don't have one.
216            // OCGs in BDC are usually referenced via Properties resource.
217            // For inline OCG dicts, check by name against the config's groups.
218            true
219        } else if type_name == b"OCMD" {
220            // OCMD — parse and check visibility
221            if let Some(ocmd) = ocg::parse_ocmd(dict) {
222                ocg::is_ocmd_visible(&ocmd, config)
223            } else {
224                true
225            }
226        } else {
227            // Check if it has /OCGs key (OCMD without /Type)
228            if dict.get(b"OCGs").is_some() {
229                if let Some(ocmd) = ocg::parse_ocmd(dict) {
230                    return ocg::is_ocmd_visible(&ocmd, config);
231                }
232            }
233            true
234        }
235    }
236
237    /// Convert operand dict entries to a PdfDict for OCG/OCMD parsing.
238    fn operand_dict_to_pdf_dict(&self, entries: &[(Vec<u8>, Operand)]) -> PdfDict {
239        let mut dict = PdfDict::new();
240        for (key, value) in entries {
241            dict.insert(key.clone(), Self::operand_to_pdf_object(value));
242        }
243        dict
244    }
245
246    /// Convert an Operand to a PdfObject (best-effort for OCG dict parsing).
247    fn operand_to_pdf_object(op: &Operand) -> PdfObject {
248        match op {
249            Operand::Integer(v) => PdfObject::Integer(*v),
250            Operand::Real(v) => PdfObject::Real(*v),
251            Operand::Bool(v) => PdfObject::Bool(*v),
252            Operand::Null => PdfObject::Null,
253            Operand::Name(n) => PdfObject::Name(n.clone()),
254            Operand::String(s) => PdfObject::String(s.clone()),
255            Operand::Array(arr) => {
256                PdfObject::Array(arr.iter().map(Self::operand_to_pdf_object).collect())
257            }
258            Operand::Dict(entries) => {
259                let mut dict = PdfDict::new();
260                for (k, v) in entries {
261                    dict.insert(k.clone(), Self::operand_to_pdf_object(v));
262                }
263                PdfObject::Dict(dict)
264            }
265            Operand::InlineImage { .. } => PdfObject::Null,
266        }
267    }
268
269    fn resolve_page_fonts(&mut self, page: &PageInfo) -> Result<()> {
270        let resources_obj = match &page.resources_ref {
271            Some(obj) => self.resolve_object(obj)?,
272            None => return Ok(()),
273        };
274
275        let resources_dict = match &resources_obj {
276            PdfObject::Dict(d) => d.clone(),
277            _ => return Ok(()),
278        };
279
280        let font_dict_obj = match resources_dict.get(b"Font") {
281            Some(PdfObject::Dict(d)) => PdfObject::Dict(d.clone()),
282            Some(PdfObject::Reference(r)) => {
283                let r = r.clone();
284                self.doc.resolve(&r)?
285            }
286            _ => return Ok(()),
287        };
288
289        if let PdfObject::Dict(font_dict) = &font_dict_obj {
290            for (name, val) in font_dict.iter() {
291                let font_obj = match val {
292                    PdfObject::Reference(r) => {
293                        let r = r.clone();
294                        self.doc.resolve(&r)?
295                    }
296                    other => other.clone(),
297                };
298
299                if let PdfObject::Dict(fd) = &font_obj {
300                    let mut info = parse_font_info(fd);
301
302                    // Resolve ToUnicode CMap
303                    let cmap = if let Some(PdfObject::Reference(tu_ref)) = fd.get(b"ToUnicode") {
304                        let tu_ref = tu_ref.clone();
305                        if let Ok(tu_obj) = self.doc.resolve(&tu_ref) {
306                            if let PdfObject::Stream { dict, data } = tu_obj {
307                                let decoded = self.doc.decode_stream(&dict, &data).ok();
308                                decoded.map(|d| ToUnicodeCMap::parse(&d))
309                            } else {
310                                None
311                            }
312                        } else {
313                            None
314                        }
315                    } else {
316                        None
317                    };
318
319                    // Resolve CIDFont widths, font descriptor, and CIDToGIDMap for Type0 fonts
320                    let mut cid_font_descriptor: Option<PdfDict> = None;
321                    let mut cid_to_gid_map: Option<Vec<u16>> = None;
322                    if info.subtype == b"Type0" {
323                        if let Some(PdfObject::Array(descendants)) =
324                            fd.get(b"DescendantFonts")
325                        {
326                            if let Some(desc_ref) = descendants.first() {
327                                let desc_obj = match desc_ref {
328                                    PdfObject::Reference(r) => {
329                                        let r = r.clone();
330                                        self.doc.resolve(&r)?
331                                    }
332                                    other => other.clone(),
333                                };
334                                if let PdfObject::Dict(cid_dict) = &desc_obj {
335                                    let cid_info = parse_font_info(cid_dict);
336                                    info.widths = cid_info.widths;
337                                    // Get font descriptor from CID font
338                                    if let Some(fd_obj) = cid_dict.get(b"FontDescriptor") {
339                                        let fd_resolved = match fd_obj {
340                                            PdfObject::Reference(r) => {
341                                                let r = r.clone();
342                                                self.doc.resolve(&r).ok()
343                                            }
344                                            other => Some(other.clone()),
345                                        };
346                                        if let Some(PdfObject::Dict(d)) = fd_resolved {
347                                            cid_font_descriptor = Some(d);
348                                        }
349                                    }
350
351                                    // Parse CIDToGIDMap
352                                    cid_to_gid_map =
353                                        self.parse_cid_to_gid_map(cid_dict);
354                                }
355                            }
356                        }
357                    }
358
359                    // Extract embedded font data from FontDescriptor
360                    let font_data = self.extract_font_data(
361                        fd,
362                        cid_font_descriptor.as_ref(),
363                    );
364
365                    self.fonts.insert(
366                        name.clone(),
367                        ResolvedFont {
368                            info,
369                            cmap,
370                            font_data,
371                            cid_to_gid_map,
372                        },
373                    );
374                }
375            }
376        }
377
378        Ok(())
379    }
380
381    /// Extract embedded font program from FontDescriptor.
382    /// Looks for FontFile, FontFile2 (TrueType), FontFile3 (CFF/OpenType).
383    fn extract_font_data(
384        &mut self,
385        font_dict: &PdfDict,
386        cid_descriptor: Option<&PdfDict>,
387    ) -> Option<Vec<u8>> {
388        // First try the font's own descriptor, then CID font descriptor
389        let descriptor = self
390            .get_font_descriptor(font_dict)
391            .or_else(|| cid_descriptor.cloned());
392
393        let descriptor = descriptor?;
394
395        // Try FontFile2 (TrueType), FontFile3 (CFF/OpenType), FontFile (Type1)
396        for key in &[b"FontFile2".as_slice(), b"FontFile3", b"FontFile"] {
397            if let Some(obj) = descriptor.get(*key) {
398                let stream_obj = match obj {
399                    PdfObject::Reference(r) => {
400                        let r = r.clone();
401                        self.doc.resolve(&r).ok()
402                    }
403                    other => Some(other.clone()),
404                };
405                if let Some(PdfObject::Stream { dict, data }) = stream_obj {
406                    if let Ok(decoded) = self.doc.decode_stream(&dict, &data) {
407                        return Some(decoded);
408                    }
409                }
410            }
411        }
412
413        None
414    }
415
416    fn get_font_descriptor(&mut self, font_dict: &PdfDict) -> Option<PdfDict> {
417        let fd_obj = font_dict.get(b"FontDescriptor")?;
418        let resolved = match fd_obj {
419            PdfObject::Reference(r) => {
420                let r = r.clone();
421                self.doc.resolve(&r).ok()
422            }
423            other => Some(other.clone()),
424        };
425        match resolved {
426            Some(PdfObject::Dict(d)) => Some(d),
427            _ => None,
428        }
429    }
430
431    /// Parse CIDToGIDMap from a CID font dictionary.
432    /// Returns None for Identity mapping (CID == GID) or if not present.
433    /// Returns Some(vec) for stream-based mapping (2 bytes per CID entry).
434    fn parse_cid_to_gid_map(&mut self, cid_dict: &PdfDict) -> Option<Vec<u16>> {
435        let map_obj = cid_dict.get(b"CIDToGIDMap")?;
436
437        match map_obj {
438            PdfObject::Name(n) if n == b"Identity" => {
439                // Identity mapping: CID == GID
440                None
441            }
442            PdfObject::Reference(r) => {
443                let r = r.clone();
444                let resolved = self.doc.resolve(&r).ok()?;
445                if let PdfObject::Stream { dict, data } = resolved {
446                    let decoded = self.doc.decode_stream(&dict, &data).ok()?;
447                    Some(parse_cid_gid_stream(&decoded))
448                } else {
449                    None
450                }
451            }
452            PdfObject::Stream { dict, data } => {
453                let decoded = self.doc.decode_stream(dict, data).ok()?;
454                Some(parse_cid_gid_stream(&decoded))
455            }
456            _ => None,
457        }
458    }
459
460    fn resolve_object(&mut self, obj: &PdfObject) -> Result<PdfObject> {
461        match obj {
462            PdfObject::Reference(r) => {
463                let r = r.clone();
464                Ok(self.doc.resolve(&r)?)
465            }
466            other => Ok(other.clone()),
467        }
468    }
469
470    fn get_page_content(&mut self, page: &PageInfo) -> Result<Vec<u8>> {
471        let contents = match &page.contents_ref {
472            Some(c) => c.clone(),
473            None => return Ok(Vec::new()),
474        };
475
476        match &contents {
477            PdfObject::Reference(r) => {
478                let r = r.clone();
479                let obj = self.doc.resolve(&r)?;
480                match obj {
481                    PdfObject::Stream { dict, data } => {
482                        Ok(self.doc.decode_stream(&dict, &data).unwrap_or_default())
483                    }
484                    PdfObject::Array(arr) => self.concat_content_streams(&arr),
485                    _ => Ok(Vec::new()),
486                }
487            }
488            PdfObject::Array(arr) => {
489                let arr = arr.clone();
490                self.concat_content_streams(&arr)
491            }
492            PdfObject::Stream { dict, data } => {
493                Ok(self.doc.decode_stream(dict, data).unwrap_or_default())
494            }
495            _ => Ok(Vec::new()),
496        }
497    }
498
499    fn concat_content_streams(&mut self, arr: &[PdfObject]) -> Result<Vec<u8>> {
500        let mut combined = Vec::new();
501        for item in arr {
502            let obj = match item {
503                PdfObject::Reference(r) => {
504                    let r = r.clone();
505                    self.doc.resolve(&r)?
506                }
507                other => other.clone(),
508            };
509            if let PdfObject::Stream { dict, data } = obj {
510                // Skip streams that fail to decode (corrupt data)
511                if let Ok(decoded) = self.doc.decode_stream(&dict, &data) {
512                    combined.extend_from_slice(&decoded);
513                    combined.push(b' ');
514                }
515            }
516        }
517        Ok(combined)
518    }
519
520    fn execute_ops(&mut self, ops: &[ContentOp], page: &PageInfo) -> Result<()> {
521        for op in ops {
522            let operator = op.operator_str();
523
524            // Handle OCG skip: when inside a hidden layer, only track
525            // BMC/BDC/EMC nesting to know when to resume.
526            if self.oc_skip_depth > 0 {
527                match operator {
528                    "BMC" | "BDC" => self.oc_skip_depth += 1,
529                    "EMC" => self.oc_skip_depth -= 1,
530                    _ => {}
531                }
532                continue;
533            }
534
535            self.execute_op(op, page)?;
536        }
537        Ok(())
538    }
539
540    fn execute_op(&mut self, op: &ContentOp, page: &PageInfo) -> Result<()> {
541        let operator = op.operator_str();
542        let operands = &op.operands;
543
544        match operator {
545            // --- Graphics state ---
546            "q" => {
547                self.state_stack.push(self.state.clone());
548            }
549            "Q" => {
550                let had_clip = self.state.has_clip;
551                if let Some(s) = self.state_stack.pop() {
552                    self.state = s;
553                }
554                // If the popped state had a clip but restored state doesn't,
555                // we need to clear the device clip
556                if had_clip && !self.state.has_clip {
557                    self.device.clear_clip();
558                    // Re-apply clip from state stack if any parent has one
559                    // (simplified: just clear for now)
560                }
561            }
562            "cm" => {
563                if operands.len() >= 6 {
564                    let m = Matrix {
565                        a: f(operands, 0),
566                        b: f(operands, 1),
567                        c: f(operands, 2),
568                        d: f(operands, 3),
569                        e: f(operands, 4),
570                        f: f(operands, 5),
571                    };
572                    self.state.ctm = m.concat(&self.state.ctm);
573                }
574            }
575
576            // Line parameters
577            "w" => {
578                if let Some(v) = operands.first().and_then(|o| o.as_f64()) {
579                    self.state.line_width = v;
580                }
581            }
582            "J" => {
583                if let Some(v) = operands.first().and_then(|o| o.as_i64()) {
584                    self.state.line_cap = match v {
585                        1 => LineCap::Round,
586                        2 => LineCap::Square,
587                        _ => LineCap::Butt,
588                    };
589                }
590            }
591            "j" => {
592                if let Some(v) = operands.first().and_then(|o| o.as_i64()) {
593                    self.state.line_join = match v {
594                        1 => LineJoin::Round,
595                        2 => LineJoin::Bevel,
596                        _ => LineJoin::Miter,
597                    };
598                }
599            }
600            "M" => {
601                if let Some(v) = operands.first().and_then(|o| o.as_f64()) {
602                    self.state.miter_limit = v;
603                }
604            }
605            "d" => {
606                // [array] phase
607                if operands.len() >= 2 {
608                    if let Some(arr) = operands[0].as_array() {
609                        self.state.dash_pattern =
610                            arr.iter().filter_map(|o| o.as_f64()).collect();
611                    }
612                    self.state.dash_phase = f(operands, 1);
613                }
614            }
615
616            // ExtGState
617            "gs" => {
618                if let Some(name) = operands.first().and_then(|o| o.as_name()) {
619                    self.apply_extgstate(name, page)?;
620                }
621            }
622
623            // --- Path construction ---
624            "m" => {
625                let pb = self.path_builder.get_or_insert_with(PathBuilder::new);
626                pb.move_to(f(operands, 0) as f32, f(operands, 1) as f32);
627            }
628            "l" => {
629                if let Some(pb) = &mut self.path_builder {
630                    pb.line_to(f(operands, 0) as f32, f(operands, 1) as f32);
631                }
632            }
633            "c" => {
634                if let Some(pb) = &mut self.path_builder {
635                    pb.cubic_to(
636                        f(operands, 0) as f32,
637                        f(operands, 1) as f32,
638                        f(operands, 2) as f32,
639                        f(operands, 3) as f32,
640                        f(operands, 4) as f32,
641                        f(operands, 5) as f32,
642                    );
643                }
644            }
645            "v" => {
646                // current point as first control point
647                if let Some(pb) = &mut self.path_builder {
648                    // tiny-skia doesn't have v/y directly, use cubic with same start
649                    // For 'v': cp1 = current point — but we don't track it here,
650                    // so we approximate with a cubic. This is lossy without current point tracking.
651                    // A proper implementation would track last point. For now, use cubic_to
652                    // with first control = last moved point (not perfectly correct for all cases).
653                    pb.cubic_to(
654                        f(operands, 0) as f32, // actually should be current point
655                        f(operands, 1) as f32,
656                        f(operands, 0) as f32,
657                        f(operands, 1) as f32,
658                        f(operands, 2) as f32,
659                        f(operands, 3) as f32,
660                    );
661                }
662            }
663            "y" => {
664                if let Some(pb) = &mut self.path_builder {
665                    // 'y': cp2 = end point
666                    pb.cubic_to(
667                        f(operands, 0) as f32,
668                        f(operands, 1) as f32,
669                        f(operands, 2) as f32,
670                        f(operands, 3) as f32,
671                        f(operands, 2) as f32,
672                        f(operands, 3) as f32,
673                    );
674                }
675            }
676            "h" => {
677                if let Some(pb) = &mut self.path_builder {
678                    pb.close();
679                }
680            }
681            "re" => {
682                // Rectangle: x y width height
683                if operands.len() >= 4 {
684                    let x = f(operands, 0) as f32;
685                    let y = f(operands, 1) as f32;
686                    let w = f(operands, 2) as f32;
687                    let h = f(operands, 3) as f32;
688                    let pb = self.path_builder.get_or_insert_with(PathBuilder::new);
689                    pb.move_to(x, y);
690                    pb.line_to(x + w, y);
691                    pb.line_to(x + w, y + h);
692                    pb.line_to(x, y + h);
693                    pb.close();
694                }
695            }
696
697            // --- Path painting ---
698            "S" => {
699                // Stroke
700                self.stroke_current_path(page);
701            }
702            "s" => {
703                // Close and stroke
704                if let Some(pb) = &mut self.path_builder {
705                    pb.close();
706                }
707                self.stroke_current_path(page);
708            }
709            "f" | "F" => {
710                // Fill (non-zero winding)
711                self.fill_current_path(FillRule::Winding, page);
712            }
713            "f*" => {
714                // Fill (even-odd)
715                self.fill_current_path(FillRule::EvenOdd, page);
716            }
717            "B" => {
718                // Fill and stroke (non-zero)
719                self.fill_current_path_keep(FillRule::Winding, page);
720                self.stroke_current_path(page);
721            }
722            "B*" => {
723                self.fill_current_path_keep(FillRule::EvenOdd, page);
724                self.stroke_current_path(page);
725            }
726            "b" => {
727                if let Some(pb) = &mut self.path_builder {
728                    pb.close();
729                }
730                self.fill_current_path_keep(FillRule::Winding, page);
731                self.stroke_current_path(page);
732            }
733            "b*" => {
734                if let Some(pb) = &mut self.path_builder {
735                    pb.close();
736                }
737                self.fill_current_path_keep(FillRule::EvenOdd, page);
738                self.stroke_current_path(page);
739            }
740            "n" => {
741                // End path without fill/stroke (used for clipping)
742                self.path_builder = None;
743            }
744
745            // --- Clipping ---
746            "W" => {
747                self.apply_clip(FillRule::Winding);
748            }
749            "W*" => {
750                self.apply_clip(FillRule::EvenOdd);
751            }
752
753            // --- Color operators ---
754            "CS" => {
755                if let Some(name) = operands.first().and_then(|o| o.as_name()) {
756                    self.state.stroke_cs = cs_from_name(name);
757                    if name != b"Pattern" {
758                        self.state.stroke_pattern_name = None;
759                    }
760                }
761            }
762            "cs" => {
763                if let Some(name) = operands.first().and_then(|o| o.as_name()) {
764                    self.state.fill_cs = cs_from_name(name);
765                    if name != b"Pattern" {
766                        self.state.fill_pattern_name = None;
767                    }
768                }
769            }
770            "SC" | "SCN" => {
771                // Last operand may be a pattern name if stroke CS is Pattern
772                let last_is_name = operands.last().and_then(|o| o.as_name());
773                if last_is_name.is_some() {
774                    self.state.stroke_pattern_name =
775                        last_is_name.map(|n| n.to_vec());
776                    // Remaining numeric operands are underlying color components
777                    let comps: Vec<f64> = operands.iter().filter_map(|o| o.as_f64()).collect();
778                    if !comps.is_empty() {
779                        self.state.stroke_color = PdfColor { components: comps };
780                    }
781                } else {
782                    let comps: Vec<f64> = operands.iter().filter_map(|o| o.as_f64()).collect();
783                    if !comps.is_empty() {
784                        self.state.stroke_color = PdfColor { components: comps };
785                    }
786                }
787            }
788            "sc" | "scn" => {
789                // Last operand may be a pattern name if fill CS is Pattern
790                let last_is_name = operands.last().and_then(|o| o.as_name());
791                if last_is_name.is_some() {
792                    self.state.fill_pattern_name =
793                        last_is_name.map(|n| n.to_vec());
794                    // Remaining numeric operands are underlying color components
795                    let comps: Vec<f64> = operands.iter().filter_map(|o| o.as_f64()).collect();
796                    if !comps.is_empty() {
797                        self.state.fill_color = PdfColor { components: comps };
798                    }
799                } else {
800                    let comps: Vec<f64> = operands.iter().filter_map(|o| o.as_f64()).collect();
801                    if !comps.is_empty() {
802                        self.state.fill_color = PdfColor { components: comps };
803                    }
804                }
805            }
806            "G" => {
807                self.state.stroke_cs = ColorSpace::DeviceGray;
808                self.state.stroke_color = PdfColor::gray(f(operands, 0));
809            }
810            "g" => {
811                self.state.fill_cs = ColorSpace::DeviceGray;
812                self.state.fill_color = PdfColor::gray(f(operands, 0));
813            }
814            "RG" => {
815                self.state.stroke_cs = ColorSpace::DeviceRGB;
816                self.state.stroke_color =
817                    PdfColor::rgb(f(operands, 0), f(operands, 1), f(operands, 2));
818            }
819            "rg" => {
820                self.state.fill_cs = ColorSpace::DeviceRGB;
821                self.state.fill_color =
822                    PdfColor::rgb(f(operands, 0), f(operands, 1), f(operands, 2));
823            }
824            "K" => {
825                self.state.stroke_cs = ColorSpace::DeviceCMYK;
826                self.state.stroke_color = PdfColor::cmyk(
827                    f(operands, 0),
828                    f(operands, 1),
829                    f(operands, 2),
830                    f(operands, 3),
831                );
832            }
833            "k" => {
834                self.state.fill_cs = ColorSpace::DeviceCMYK;
835                self.state.fill_color = PdfColor::cmyk(
836                    f(operands, 0),
837                    f(operands, 1),
838                    f(operands, 2),
839                    f(operands, 3),
840                );
841            }
842
843            // --- Text operators ---
844            "BT" => {
845                self.state.text_matrix = Matrix::identity();
846                self.state.text_line_matrix = Matrix::identity();
847            }
848            "ET" => {}
849            "Tc" => {
850                self.state.text.char_spacing = f(operands, 0);
851            }
852            "Tw" => {
853                self.state.text.word_spacing = f(operands, 0);
854            }
855            "Tz" => {
856                self.state.text.horiz_scaling = f(operands, 0) / 100.0;
857            }
858            "TL" => {
859                self.state.text.leading = f(operands, 0);
860            }
861            "Tf" => {
862                if let Some(name) = operands.first().and_then(|o| o.as_name()) {
863                    self.state.text.font_name = name.to_vec();
864                }
865                if operands.len() > 1 {
866                    self.state.text.font_size = f(operands, 1);
867                }
868            }
869            "Tr" => {
870                self.state.text.render_mode = operands.first().and_then(|o| o.as_i64()).unwrap_or(0);
871            }
872            "Ts" => {
873                self.state.text.text_rise = f(operands, 0);
874            }
875            "Td" => {
876                let tx = f(operands, 0);
877                let ty = f(operands, 1);
878                let t = Matrix::translate(tx, ty);
879                self.state.text_line_matrix = t.concat(&self.state.text_line_matrix);
880                self.state.text_matrix = self.state.text_line_matrix;
881            }
882            "TD" => {
883                let tx = f(operands, 0);
884                let ty = f(operands, 1);
885                self.state.text.leading = -ty;
886                let t = Matrix::translate(tx, ty);
887                self.state.text_line_matrix = t.concat(&self.state.text_line_matrix);
888                self.state.text_matrix = self.state.text_line_matrix;
889            }
890            "Tm" => {
891                if operands.len() >= 6 {
892                    let m = Matrix {
893                        a: f(operands, 0),
894                        b: f(operands, 1),
895                        c: f(operands, 2),
896                        d: f(operands, 3),
897                        e: f(operands, 4),
898                        f: f(operands, 5),
899                    };
900                    self.state.text_matrix = m;
901                    self.state.text_line_matrix = m;
902                }
903            }
904            "T*" => {
905                let leading = self.state.text.leading;
906                let t = Matrix::translate(0.0, -leading);
907                self.state.text_line_matrix = t.concat(&self.state.text_line_matrix);
908                self.state.text_matrix = self.state.text_line_matrix;
909            }
910            "Tj" => {
911                if let Some(s) = operands.first().and_then(|o| o.as_str()) {
912                    self.render_text_string(s)?;
913                }
914            }
915            "TJ" => {
916                if let Some(arr) = operands.first().and_then(|o| o.as_array()) {
917                    for item in arr {
918                        match item {
919                            Operand::String(s) => {
920                                self.render_text_string(s)?;
921                            }
922                            Operand::Integer(n) => {
923                                self.adjust_text_position(*n as f64);
924                            }
925                            Operand::Real(n) => {
926                                self.adjust_text_position(*n);
927                            }
928                            _ => {}
929                        }
930                    }
931                }
932            }
933            "'" => {
934                // Move to next line, show string
935                let leading = self.state.text.leading;
936                let t = Matrix::translate(0.0, -leading);
937                self.state.text_line_matrix = t.concat(&self.state.text_line_matrix);
938                self.state.text_matrix = self.state.text_line_matrix;
939                if let Some(s) = operands.first().and_then(|o| o.as_str()) {
940                    self.render_text_string(s)?;
941                }
942            }
943            "\"" => {
944                // Set word/char spacing, move to next line, show string
945                if operands.len() >= 3 {
946                    self.state.text.word_spacing = f(operands, 0);
947                    self.state.text.char_spacing = f(operands, 1);
948                    let leading = self.state.text.leading;
949                    let t = Matrix::translate(0.0, -leading);
950                    self.state.text_line_matrix = t.concat(&self.state.text_line_matrix);
951                    self.state.text_matrix = self.state.text_line_matrix;
952                    if let Some(s) = operands.get(2).and_then(|o| o.as_str()) {
953                        self.render_text_string(s)?;
954                    }
955                }
956            }
957
958            // --- XObject (images and forms) ---
959            "Do" => {
960                if let Some(name) = operands.first().and_then(|o| o.as_name()) {
961                    self.do_xobject(name, page)?;
962                }
963            }
964
965            // --- Inline image ---
966            "BI" => {
967                if let Some(Operand::InlineImage { dict, data }) = operands.first() {
968                    self.render_inline_image(dict, data)?;
969                }
970            }
971
972            // --- Marked content / Optional Content ---
973            "BMC" => {}
974            "BDC" => {
975                // Check if this is an OC (Optional Content) marked content
976                // BDC operands: tag properties
977                // If tag is /OC and properties is a dict with /OCGS or /OCGs,
978                // check visibility against the OCG config.
979                if let Some(config) = &self.oc_config {
980                    let tag = operands.first().and_then(|o| o.as_name());
981                    if tag == Some(b"OC") {
982                        let visible = self.check_oc_visibility(operands, config);
983                        if !visible {
984                            self.oc_skip_depth = 1;
985                        }
986                    }
987                }
988            }
989            "EMC" => {}
990            "MP" | "DP" => {}
991
992            // --- Shading ---
993            "sh" => {
994                if let Some(name) = operands.first().and_then(|o| o.as_name()) {
995                    self.render_shading(name, page)?;
996                }
997            }
998
999            // --- Type3 font ---
1000            "d0" | "d1" => {}
1001
1002            // --- Compatibility ---
1003            "BX" | "EX" => {}
1004
1005            // Unknown operator — ignore
1006            _ => {}
1007        }
1008
1009        Ok(())
1010    }
1011
1012    // --- Path rendering helpers ---
1013
1014    fn effective_transform(&self) -> Transform {
1015        self.state.ctm.concat(&self.page_transform).to_skia()
1016    }
1017
1018    fn blend_mode(&self) -> tiny_skia::BlendMode {
1019        self.state.blend_mode.to_skia()
1020    }
1021
1022    fn fill_current_path(&mut self, rule: FillRule, page: &PageInfo) {
1023        if let Some(pb) = self.path_builder.take() {
1024            if let Some(path) = pb.finish() {
1025                // Check for pattern fill
1026                if self.state.fill_pattern_name.is_some() {
1027                    if self.try_fill_with_pattern(&path, rule, page) {
1028                        return;
1029                    }
1030                }
1031                let transform = self.effective_transform();
1032                let color = self.state.fill_color_rgba();
1033                let bm = self.blend_mode();
1034                self.apply_soft_mask_to_device();
1035                self.device.fill_path(&path, rule, transform, color, bm);
1036                self.restore_clip_after_soft_mask();
1037            }
1038        }
1039    }
1040
1041    fn fill_current_path_keep(&mut self, rule: FillRule, page: &PageInfo) {
1042        if let Some(pb) = &self.path_builder {
1043            let pb_clone = pb.clone();
1044            if let Some(path) = pb_clone.finish() {
1045                // Check for pattern fill
1046                if self.state.fill_pattern_name.is_some() {
1047                    if self.try_fill_with_pattern(&path, rule, page) {
1048                        return;
1049                    }
1050                }
1051                let transform = self.effective_transform();
1052                let color = self.state.fill_color_rgba();
1053                let bm = self.blend_mode();
1054                self.apply_soft_mask_to_device();
1055                self.device.fill_path(&path, rule, transform, color, bm);
1056                self.restore_clip_after_soft_mask();
1057            }
1058        }
1059    }
1060
1061    fn stroke_current_path(&mut self, page: &PageInfo) {
1062        if let Some(pb) = self.path_builder.take() {
1063            if let Some(path) = pb.finish() {
1064                // Check for pattern stroke
1065                if self.state.stroke_pattern_name.is_some() {
1066                    if self.try_stroke_with_pattern(&path, page) {
1067                        return;
1068                    }
1069                }
1070                let transform = self.effective_transform();
1071                let color = self.state.stroke_color_rgba();
1072                let bm = self.blend_mode();
1073                self.apply_soft_mask_to_device();
1074                self.device.stroke_path(&path, transform, color, &self.state, bm);
1075                self.restore_clip_after_soft_mask();
1076            }
1077        }
1078    }
1079
1080    /// If a soft mask is active, combine it with the current clip mask
1081    /// so that drawing operations are masked accordingly.
1082    fn apply_soft_mask_to_device(&mut self) {
1083        if let Some(ref soft_mask) = self.state.soft_mask {
1084            // Intersect the soft mask with the existing clip mask
1085            if let Some(ref existing_clip) = self.device.clip_mask {
1086                // Combine: for each pixel, min(existing_clip, soft_mask)
1087                let mut combined = existing_clip.clone();
1088                let combined_data = combined.data_mut();
1089                let mask_data = soft_mask.mask.data();
1090                let len = combined_data.len().min(mask_data.len());
1091                for i in 0..len {
1092                    combined_data[i] =
1093                        ((combined_data[i] as u16 * mask_data[i] as u16) / 255) as u8;
1094                }
1095                self.device.clip_mask = Some(combined);
1096            } else {
1097                self.device.clip_mask = Some(soft_mask.mask.clone());
1098            }
1099        }
1100    }
1101
1102    /// Restore the clip mask after soft mask application (remove the soft mask contribution).
1103    fn restore_clip_after_soft_mask(&mut self) {
1104        if self.state.soft_mask.is_some() {
1105            // Restore original clip (without the soft mask merged in)
1106            // We need to reconstruct just the clip path mask.
1107            // For simplicity, if we had a clip before, we need to re-establish it.
1108            // Since we don't track the clip path separately, we'll just leave
1109            // the combined mask in place. The Q operator will restore properly.
1110            // This is acceptable because soft masks are typically used within
1111            // a q/Q pair.
1112        }
1113    }
1114
1115    fn apply_clip(&mut self, rule: FillRule) {
1116        if let Some(pb) = &self.path_builder {
1117            let pb_clone = pb.clone();
1118            if let Some(path) = pb_clone.finish() {
1119                let transform = self.effective_transform();
1120                if self.state.has_clip {
1121                    self.device.intersect_clip_path(&path, rule, transform);
1122                } else {
1123                    self.device.set_clip_path(&path, rule, transform);
1124                }
1125                self.state.has_clip = true;
1126            }
1127        }
1128    }
1129
1130    // --- Text rendering ---
1131
1132    fn render_text_string(&mut self, string_bytes: &[u8]) -> Result<()> {
1133        let font_name = self.state.text.font_name.clone();
1134        let font = match self.fonts.get(&font_name) {
1135            Some(f) => f,
1136            None => return Ok(()), // font not found, skip
1137        };
1138
1139        let font_size = self.state.text.font_size;
1140        let horiz_scaling = self.state.text.horiz_scaling;
1141        let char_spacing = self.state.text.char_spacing;
1142        let word_spacing = self.state.text.word_spacing;
1143        let text_rise = self.state.text.text_rise;
1144        let render_mode = self.state.text.render_mode;
1145
1146        // Determine if this is a 2-byte CID font
1147        let is_cid = font.info.subtype == b"Type0";
1148
1149        // Pre-compute char codes, widths, and font data while borrowing fonts immutably
1150        let char_codes: Vec<u32> = if is_cid {
1151            string_bytes
1152                .chunks(2)
1153                .map(|c| {
1154                    if c.len() == 2 {
1155                        ((c[0] as u32) << 8) | (c[1] as u32)
1156                    } else {
1157                        c[0] as u32
1158                    }
1159                })
1160                .collect()
1161        } else {
1162            string_bytes.iter().map(|b| *b as u32).collect()
1163        };
1164
1165        let widths: Vec<f64> = char_codes
1166            .iter()
1167            .map(|code| font.info.widths.get_width(*code))
1168            .collect();
1169
1170        // Clone font_data and CIDToGIDMap for glyph outline rendering
1171        let font_data = font.font_data.clone();
1172        let cid_to_gid_map = font.cid_to_gid_map.clone();
1173
1174        // Now we're done borrowing self.fonts, can mutably borrow self
1175        for (i, code) in char_codes.iter().enumerate() {
1176            let width = widths[i];
1177            let w0 = width / 1000.0;
1178
1179            if render_mode != 3 {
1180                self.render_glyph(
1181                    *code,
1182                    w0 * font_size,
1183                    font_size,
1184                    text_rise,
1185                    is_cid,
1186                    &font_data,
1187                    cid_to_gid_map.as_deref(),
1188                )?;
1189            }
1190
1191            // Advance text matrix
1192            let tx = (w0 * font_size + char_spacing) * horiz_scaling;
1193            let tx = if *code == 32 {
1194                tx + word_spacing * horiz_scaling
1195            } else {
1196                tx
1197            };
1198
1199            let advance = Matrix::translate(tx, 0.0);
1200            self.state.text_matrix = advance.concat(&self.state.text_matrix);
1201        }
1202
1203        Ok(())
1204    }
1205
1206    fn render_glyph(
1207        &mut self,
1208        code: u32,
1209        glyph_width: f64,
1210        font_size: f64,
1211        text_rise: f64,
1212        is_cid: bool,
1213        font_data: &Option<Vec<u8>>,
1214        cid_to_gid_map: Option<&[u16]>,
1215    ) -> Result<()> {
1216        if glyph_width.abs() < 0.001 {
1217            return Ok(());
1218        }
1219
1220        // Try to render with real glyph outlines (using glyph cache)
1221        if let Some(data) = font_data {
1222            if let Ok(face) = ttf_parser::Face::parse(data, 0) {
1223                let glyph_id = if is_cid {
1224                    // For CID fonts: apply CIDToGIDMap if available
1225                    if let Some(map) = cid_to_gid_map {
1226                        let gid = map
1227                            .get(code as usize)
1228                            .copied()
1229                            .unwrap_or(code as u16);
1230                        ttf_parser::GlyphId(gid)
1231                    } else {
1232                        // Identity mapping: CID == GID
1233                        ttf_parser::GlyphId(code as u16)
1234                    }
1235                } else {
1236                    crate::glyph::char_code_to_glyph_id(&face, code)
1237                };
1238
1239                let gid_raw = glyph_id.0;
1240                let cached_path = self.glyph_cache.get_or_insert(data, gid_raw, || {
1241                    crate::glyph::glyph_outline(&face, glyph_id)
1242                }).cloned();
1243
1244                if let Some(path) = cached_path {
1245                    let upem = crate::glyph::units_per_em(&face);
1246                    if upem > 0.0 {
1247                        // Glyph coordinates are in font units.
1248                        // Scale: font_size / upem, and flip Y (font Y is up, PDF text Y is up too
1249                        // but we apply the text matrix which handles the rest)
1250                        let scale = font_size / upem;
1251                        let glyph_matrix = Matrix {
1252                            a: scale,
1253                            b: 0.0,
1254                            c: 0.0,
1255                            d: scale, // no Y flip here — glyph coords have Y-up, matching PDF
1256                            e: 0.0,
1257                            f: text_rise,
1258                        };
1259
1260                        let text_rendering_matrix = glyph_matrix
1261                            .concat(&self.state.text_matrix)
1262                            .concat(&self.state.ctm)
1263                            .concat(&self.page_transform);
1264
1265                        let transform = text_rendering_matrix.to_skia();
1266                        let color = self.state.fill_color_rgba();
1267                        let bm = self.blend_mode();
1268                        self.device
1269                            .fill_path(&path, FillRule::Winding, transform, color, bm);
1270                        return Ok(());
1271                    }
1272                }
1273            }
1274        }
1275
1276        // Fallback: render a placeholder rectangle
1277        let text_rendering_matrix = self
1278            .state
1279            .text_matrix
1280            .concat(&self.state.ctm)
1281            .concat(&self.page_transform);
1282
1283        let mut pb = PathBuilder::new();
1284        let x = 0.0_f32;
1285        let y = (text_rise - font_size * 0.2) as f32;
1286        let w = glyph_width as f32;
1287        let h = font_size as f32 * 0.8;
1288        pb.move_to(x, y);
1289        pb.line_to(x + w, y);
1290        pb.line_to(x + w, y + h);
1291        pb.line_to(x, y + h);
1292        pb.close();
1293
1294        if let Some(path) = pb.finish() {
1295            let transform = text_rendering_matrix.to_skia();
1296            let color = self.state.fill_color_rgba();
1297            let bm = self.blend_mode();
1298            self.device
1299                .fill_path(&path, FillRule::Winding, transform, color, bm);
1300        }
1301
1302        Ok(())
1303    }
1304
1305    fn adjust_text_position(&mut self, amount: f64) {
1306        // TJ adjustment: negative = move right, positive = move left
1307        let font_size = self.state.text.font_size;
1308        let horiz_scaling = self.state.text.horiz_scaling;
1309        let tx = -amount / 1000.0 * font_size * horiz_scaling;
1310        let advance = Matrix::translate(tx, 0.0);
1311        self.state.text_matrix = advance.concat(&self.state.text_matrix);
1312    }
1313
1314    // --- XObject rendering ---
1315
1316    fn do_xobject(&mut self, name: &[u8], page: &PageInfo) -> Result<()> {
1317        let xobj = self.resolve_xobject(name, page)?;
1318        let xobj = match xobj {
1319            Some(x) => x,
1320            None => return Ok(()),
1321        };
1322
1323        match xobj {
1324            XObjectData::Image { dict, data } => {
1325                let _ = self.render_image(&dict, &data); // skip broken images
1326            }
1327            XObjectData::Form { dict, data } => {
1328                if self.xobject_depth > 10 {
1329                    return Ok(()); // prevent infinite recursion
1330                }
1331                self.xobject_depth += 1;
1332                let _ = self.render_form_xobject(&dict, &data, page);
1333                self.xobject_depth -= 1;
1334            }
1335        }
1336
1337        Ok(())
1338    }
1339
1340    fn resolve_xobject(&mut self, name: &[u8], page: &PageInfo) -> Result<Option<XObjectData>> {
1341        let resources_obj = match &page.resources_ref {
1342            Some(obj) => self.resolve_object(obj)?,
1343            None => return Ok(None),
1344        };
1345
1346        let resources_dict = match &resources_obj {
1347            PdfObject::Dict(d) => d.clone(),
1348            _ => return Ok(None),
1349        };
1350
1351        let xobject_dict_obj = match resources_dict.get(b"XObject") {
1352            Some(PdfObject::Dict(d)) => PdfObject::Dict(d.clone()),
1353            Some(PdfObject::Reference(r)) => {
1354                let r = r.clone();
1355                self.doc.resolve(&r)?
1356            }
1357            _ => return Ok(None),
1358        };
1359
1360        let xobject_dict = match &xobject_dict_obj {
1361            PdfObject::Dict(d) => d,
1362            _ => return Ok(None),
1363        };
1364
1365        let xobj_ref = match xobject_dict.get(name) {
1366            Some(PdfObject::Reference(r)) => r.clone(),
1367            _ => return Ok(None),
1368        };
1369
1370        let xobj = self.doc.resolve(&xobj_ref)?;
1371
1372        match xobj {
1373            PdfObject::Stream { dict, data } => {
1374                let subtype = dict.get_name(b"Subtype").unwrap_or(b"");
1375                match subtype {
1376                    b"Image" => {
1377                        // For JPEG, pass raw data
1378                        let filter = dict.get(b"Filter").and_then(|o| o.as_name());
1379                        let image_data = if filter == Some(b"DCTDecode") {
1380                            data.clone()
1381                        } else {
1382                            match self.doc.decode_stream(&dict, &data) {
1383                                Ok(d) => d,
1384                                Err(_) => return Ok(None),
1385                            }
1386                        };
1387                        Ok(Some(XObjectData::Image {
1388                            dict,
1389                            data: image_data,
1390                        }))
1391                    }
1392                    b"Form" => {
1393                        match self.doc.decode_stream(&dict, &data) {
1394                            Ok(decoded) => Ok(Some(XObjectData::Form {
1395                                dict,
1396                                data: decoded,
1397                            })),
1398                            Err(_) => Ok(None),
1399                        }
1400                    }
1401                    _ => Ok(None),
1402                }
1403            }
1404            _ => Ok(None),
1405        }
1406    }
1407
1408    fn render_image(&mut self, dict: &PdfDict, data: &[u8]) -> Result<()> {
1409        let info = image::image_info(dict);
1410
1411        // Check for ImageMask (stencil mask)
1412        if let Some(ref img_info) = info {
1413            if img_info.is_mask {
1414                return self.render_image_mask(img_info, data, dict);
1415            }
1416        }
1417
1418        let decoded = image::decode_image(data, dict).map_err(|e| RenderError::Core(e))?;
1419
1420        // Convert decoded image to RGBA
1421        let mut rgba_data = image_to_rgba(&decoded);
1422        let w = decoded.width;
1423        let h = decoded.height;
1424
1425        // Apply SMask if present on the image dict
1426        if let Some(PdfObject::Reference(smask_ref)) = dict.get(b"SMask") {
1427            let smask_ref = smask_ref.clone();
1428            if let Ok(smask_obj) = self.doc.resolve(&smask_ref) {
1429                if let PdfObject::Stream {
1430                    dict: smask_dict,
1431                    data: smask_data,
1432                } = smask_obj
1433                {
1434                    self.apply_image_smask(
1435                        &mut rgba_data,
1436                        w,
1437                        h,
1438                        &smask_dict,
1439                        &smask_data,
1440                    );
1441                }
1442            }
1443        }
1444
1445        // Apply Mask (explicit mask) if present — a 1-bit image defining transparency
1446        if let Some(PdfObject::Reference(mask_ref)) = dict.get(b"Mask") {
1447            let mask_ref = mask_ref.clone();
1448            if let Ok(mask_obj) = self.doc.resolve(&mask_ref) {
1449                if let PdfObject::Stream {
1450                    dict: mask_dict,
1451                    data: mask_data,
1452                } = mask_obj
1453                {
1454                    self.apply_image_explicit_mask(
1455                        &mut rgba_data,
1456                        w,
1457                        h,
1458                        &mask_dict,
1459                        &mask_data,
1460                    );
1461                }
1462            }
1463        }
1464
1465        let img_pixmap =
1466            match tiny_skia::Pixmap::from_vec(rgba_data, tiny_skia::IntSize::from_wh(w, h).unwrap())
1467            {
1468                Some(p) => p,
1469                None => return Ok(()),
1470            };
1471
1472        // PDF images are placed in a 1x1 unit square, scaled by the CTM
1473        let image_transform = Matrix {
1474            a: 1.0 / w as f64,
1475            b: 0.0,
1476            c: 0.0,
1477            d: -1.0 / h as f64, // flip Y (PDF images are top-down)
1478            e: 0.0,
1479            f: 1.0,
1480        };
1481
1482        let full_transform = image_transform
1483            .concat(&self.state.ctm)
1484            .concat(&self.page_transform);
1485
1486        let bm = self.blend_mode();
1487        self.apply_soft_mask_to_device();
1488        self.device.draw_image(
1489            &img_pixmap.as_ref(),
1490            full_transform.to_skia(),
1491            self.state.fill_alpha as f32,
1492            bm,
1493        );
1494        self.restore_clip_after_soft_mask();
1495
1496        Ok(())
1497    }
1498
1499    /// Render a 1-bit image mask (stencil mask): paint current fill color
1500    /// where mask bits are set.
1501    fn render_image_mask(
1502        &mut self,
1503        info: &image::ImageInfo,
1504        data: &[u8],
1505        dict: &PdfDict,
1506    ) -> Result<()> {
1507        let w = info.width;
1508        let h = info.height;
1509        let pixel_count = (w * h) as usize;
1510
1511        // Decode the mask data if it has filters
1512        let decoded_data = match dict.get(b"Filter") {
1513            Some(_) => {
1514                match image::decode_image(data, dict) {
1515                    Ok(img) => img.data,
1516                    Err(_) => data.to_vec(),
1517                }
1518            }
1519            None => data.to_vec(),
1520        };
1521
1522        // Get the Decode array to determine polarity
1523        // Default for ImageMask: [0 1] means 0=paint, 1=mask (transparent)
1524        let invert = dict
1525            .get_array(b"Decode")
1526            .map(|arr| {
1527                let d0 = arr.first().and_then(|o| o.as_f64()).unwrap_or(0.0);
1528                d0 != 0.0 // [1 0] means inverted
1529            })
1530            .unwrap_or(false);
1531
1532        let fill_color = self.state.fill_color_rgba();
1533        let mut rgba = vec![0u8; pixel_count * 4];
1534
1535        // Unpack bits: the data is 1 bit per pixel, packed MSB first
1536        for i in 0..pixel_count {
1537            let byte_idx = i / 8;
1538            let bit_idx = 7 - (i % 8);
1539            let bit = if byte_idx < decoded_data.len() {
1540                (decoded_data[byte_idx] >> bit_idx) & 1
1541            } else {
1542                0
1543            };
1544
1545            // Determine if this pixel should be painted
1546            let paint = if invert { bit == 1 } else { bit == 0 };
1547
1548            if paint {
1549                rgba[i * 4] = fill_color[0];
1550                rgba[i * 4 + 1] = fill_color[1];
1551                rgba[i * 4 + 2] = fill_color[2];
1552                rgba[i * 4 + 3] = fill_color[3];
1553            }
1554            // else: transparent (0,0,0,0)
1555        }
1556
1557        let img_pixmap =
1558            match tiny_skia::Pixmap::from_vec(rgba, tiny_skia::IntSize::from_wh(w, h).unwrap()) {
1559                Some(p) => p,
1560                None => return Ok(()),
1561            };
1562
1563        let image_transform = Matrix {
1564            a: 1.0 / w as f64,
1565            b: 0.0,
1566            c: 0.0,
1567            d: -1.0 / h as f64,
1568            e: 0.0,
1569            f: 1.0,
1570        };
1571
1572        let full_transform = image_transform
1573            .concat(&self.state.ctm)
1574            .concat(&self.page_transform);
1575
1576        let bm = self.blend_mode();
1577        self.device.draw_image(
1578            &img_pixmap.as_ref(),
1579            full_transform.to_skia(),
1580            self.state.fill_alpha as f32,
1581            bm,
1582        );
1583
1584        Ok(())
1585    }
1586
1587    /// Apply an SMask (soft mask) from the image's own /SMask entry to the RGBA data.
1588    fn apply_image_smask(
1589        &mut self,
1590        rgba: &mut [u8],
1591        w: u32,
1592        h: u32,
1593        smask_dict: &PdfDict,
1594        smask_data: &[u8],
1595    ) {
1596        // Decode the SMask image
1597        let decoded = match self.doc.decode_stream(smask_dict, smask_data) {
1598            Ok(d) => d,
1599            Err(_) => return,
1600        };
1601
1602        let smask_decoded = match image::decode_image(&decoded, smask_dict) {
1603            Ok(img) => img,
1604            Err(_) => return,
1605        };
1606
1607        let pixel_count = (w * h) as usize;
1608        let mask_pixels = smask_decoded.data;
1609
1610        // SMask is typically a grayscale image — use its values as alpha
1611        for i in 0..pixel_count {
1612            let mask_val = if smask_decoded.components == 1 {
1613                // Grayscale: direct alpha value
1614                if smask_decoded.bpc == 8 {
1615                    mask_pixels.get(i).copied().unwrap_or(255)
1616                } else if smask_decoded.bpc == 1 {
1617                    if mask_pixels.get(i).copied().unwrap_or(255) != 0 {
1618                        255
1619                    } else {
1620                        0
1621                    }
1622                } else {
1623                    mask_pixels.get(i).copied().unwrap_or(255)
1624                }
1625            } else {
1626                // Multi-component: use luminosity
1627                let idx = i * smask_decoded.components as usize;
1628                let r = mask_pixels.get(idx).copied().unwrap_or(255) as f32;
1629                let g = mask_pixels.get(idx + 1).copied().unwrap_or(255) as f32;
1630                let b = mask_pixels.get(idx + 2).copied().unwrap_or(255) as f32;
1631                (0.2126 * r + 0.7152 * g + 0.0722 * b).clamp(0.0, 255.0) as u8
1632            };
1633
1634            // Multiply existing alpha with mask value
1635            let existing_alpha = rgba[i * 4 + 3] as u16;
1636            rgba[i * 4 + 3] = ((existing_alpha * mask_val as u16) / 255) as u8;
1637        }
1638    }
1639
1640    /// Apply an explicit /Mask image (1-bit transparency mask) to the RGBA data.
1641    fn apply_image_explicit_mask(
1642        &mut self,
1643        rgba: &mut [u8],
1644        w: u32,
1645        h: u32,
1646        mask_dict: &PdfDict,
1647        mask_data: &[u8],
1648    ) {
1649        let decoded = match self.doc.decode_stream(mask_dict, mask_data) {
1650            Ok(d) => d,
1651            Err(_) => return,
1652        };
1653
1654        let mask_decoded = match image::decode_image(&decoded, mask_dict) {
1655            Ok(img) => img,
1656            Err(_) => return,
1657        };
1658
1659        let pixel_count = (w * h) as usize;
1660
1661        // Scale mask to image dimensions if different
1662        let mask_w = mask_decoded.width as usize;
1663        let mask_h = mask_decoded.height as usize;
1664        let img_w = w as usize;
1665        let img_h = h as usize;
1666
1667        for y in 0..img_h {
1668            for x in 0..img_w {
1669                let i = y * img_w + x;
1670                if i >= pixel_count {
1671                    break;
1672                }
1673
1674                // Map to mask coordinates
1675                let mx = if mask_w > 0 { x * mask_w / img_w } else { 0 };
1676                let my = if mask_h > 0 { y * mask_h / img_h } else { 0 };
1677                let mi = my * mask_w + mx;
1678
1679                let mask_bit = if mask_decoded.bpc == 1 {
1680                    // 1-bit packed
1681                    let byte_idx = mi / 8;
1682                    let bit_idx = 7 - (mi % 8);
1683                    if byte_idx < mask_decoded.data.len() {
1684                        (mask_decoded.data[byte_idx] >> bit_idx) & 1
1685                    } else {
1686                        1
1687                    }
1688                } else {
1689                    // 8bpc or higher
1690                    if mi < mask_decoded.data.len() {
1691                        if mask_decoded.data[mi] > 127 { 1 } else { 0 }
1692                    } else {
1693                        1
1694                    }
1695                };
1696
1697                // In PDF, mask bit 0 = paint (opaque), 1 = do not paint (transparent)
1698                if mask_bit == 1 {
1699                    rgba[i * 4 + 3] = 0; // transparent
1700                }
1701            }
1702        }
1703    }
1704
1705    fn render_inline_image(
1706        &mut self,
1707        _dict: &[(Vec<u8>, Operand)],
1708        _data: &[u8],
1709    ) -> Result<()> {
1710        // TODO: implement inline image rendering
1711        Ok(())
1712    }
1713
1714    fn render_shading(&mut self, name: &[u8], page: &PageInfo) -> Result<()> {
1715        let resources_obj = match &page.resources_ref {
1716            Some(obj) => self.resolve_object(obj)?,
1717            None => return Ok(()),
1718        };
1719
1720        let resources_dict = match &resources_obj {
1721            PdfObject::Dict(d) => d.clone(),
1722            _ => return Ok(()),
1723        };
1724
1725        let shading_dict_obj = match resources_dict.get(b"Shading") {
1726            Some(PdfObject::Dict(d)) => PdfObject::Dict(d.clone()),
1727            Some(PdfObject::Reference(r)) => {
1728                let r = r.clone();
1729                self.doc.resolve(&r)?
1730            }
1731            _ => return Ok(()),
1732        };
1733
1734        let shading_container = match &shading_dict_obj {
1735            PdfObject::Dict(d) => d,
1736            _ => return Ok(()),
1737        };
1738
1739        let sh_obj = match shading_container.get(name) {
1740            Some(PdfObject::Reference(r)) => {
1741                let r = r.clone();
1742                self.doc.resolve(&r)?
1743            }
1744            Some(other) => other.clone(),
1745            None => return Ok(()),
1746        };
1747
1748        // Extract dict and optional stream data from the shading object
1749        let (sh_dict, stream_data) = match &sh_obj {
1750            PdfObject::Dict(d) => (d.clone(), None),
1751            PdfObject::Stream { dict, data } => {
1752                let decoded = self.doc.decode_stream(dict, data).ok();
1753                (dict.clone(), decoded)
1754            }
1755            _ => return Ok(()),
1756        };
1757
1758        // Resolve function if it's a reference
1759        let mut resolved_dict = sh_dict;
1760        if let Some(PdfObject::Reference(func_ref)) = resolved_dict.get(b"Function").cloned() {
1761            if let Ok(func_obj) = self.doc.resolve(&func_ref) {
1762                resolved_dict.insert(b"Function".to_vec(), func_obj);
1763            }
1764        }
1765
1766        let clip = self.device.clip_mask.as_ref();
1767        crate::shading::render_shading(
1768            &mut self.device.pixmap,
1769            &resolved_dict,
1770            &self.state.ctm,
1771            &self.page_transform,
1772            clip,
1773            stream_data.as_deref(),
1774        );
1775
1776        Ok(())
1777    }
1778
1779    fn render_form_xobject(
1780        &mut self,
1781        dict: &PdfDict,
1782        data: &[u8],
1783        page: &PageInfo,
1784    ) -> Result<()> {
1785        // Check if this form has a transparency group
1786        let has_transparency_group = dict
1787            .get(b"Group")
1788            .and_then(|o| match o {
1789                PdfObject::Dict(d) => Some(d),
1790                _ => None,
1791            })
1792            .map(|group| group.get_name(b"S") == Some(b"Transparency"))
1793            .unwrap_or(false);
1794
1795        if has_transparency_group {
1796            return self.render_transparency_group(dict, data, page);
1797        }
1798
1799        // Save state
1800        self.state_stack.push(self.state.clone());
1801
1802        // Apply form matrix if present
1803        if let Some(matrix_arr) = dict.get_array(b"Matrix") {
1804            if matrix_arr.len() >= 6 {
1805                let m = Matrix {
1806                    a: matrix_arr[0].as_f64().unwrap_or(1.0),
1807                    b: matrix_arr[1].as_f64().unwrap_or(0.0),
1808                    c: matrix_arr[2].as_f64().unwrap_or(0.0),
1809                    d: matrix_arr[3].as_f64().unwrap_or(1.0),
1810                    e: matrix_arr[4].as_f64().unwrap_or(0.0),
1811                    f: matrix_arr[5].as_f64().unwrap_or(0.0),
1812                };
1813                self.state.ctm = m.concat(&self.state.ctm);
1814            }
1815        }
1816
1817        // Parse and execute the form's content stream
1818        let ops = parse_content_stream(data).map_err(|e| RenderError::Core(e))?;
1819        self.execute_ops(&ops, page)?;
1820
1821        // Restore state
1822        if let Some(s) = self.state_stack.pop() {
1823            self.state = s;
1824        }
1825
1826        Ok(())
1827    }
1828
1829    /// Render a transparency group: draw content into a temporary pixmap,
1830    /// then composite onto the main device.
1831    fn render_transparency_group(
1832        &mut self,
1833        dict: &PdfDict,
1834        data: &[u8],
1835        page: &PageInfo,
1836    ) -> Result<()> {
1837        let w = self.device.pixmap.width();
1838        let h = self.device.pixmap.height();
1839
1840        // Create temporary pixmap for the group
1841        let mut temp_pixmap = match Pixmap::new(w, h) {
1842            Some(p) => p,
1843            None => {
1844                // Fallback: render directly (non-grouped)
1845                return self.render_form_xobject_direct(dict, data, page);
1846            }
1847        };
1848
1849        // Check if group is isolated (starts with transparent backdrop)
1850        let is_isolated = dict
1851            .get(b"Group")
1852            .and_then(|o| match o {
1853                PdfObject::Dict(d) => Some(d),
1854                _ => None,
1855            })
1856            .and_then(|group| group.get(b"I"))
1857            .and_then(|o| o.as_bool())
1858            .unwrap_or(false);
1859
1860        // If not isolated, copy the current pixmap as backdrop
1861        if !is_isolated {
1862            temp_pixmap
1863                .data_mut()
1864                .copy_from_slice(self.device.pixmap.data());
1865        }
1866
1867        // Swap in the temp pixmap
1868        let saved_clip = self.device.clip_mask.take();
1869        std::mem::swap(&mut self.device.pixmap, &mut temp_pixmap);
1870
1871        // Save state
1872        self.state_stack.push(self.state.clone());
1873
1874        // Apply form matrix if present
1875        if let Some(matrix_arr) = dict.get_array(b"Matrix") {
1876            if matrix_arr.len() >= 6 {
1877                let m = Matrix {
1878                    a: matrix_arr[0].as_f64().unwrap_or(1.0),
1879                    b: matrix_arr[1].as_f64().unwrap_or(0.0),
1880                    c: matrix_arr[2].as_f64().unwrap_or(0.0),
1881                    d: matrix_arr[3].as_f64().unwrap_or(1.0),
1882                    e: matrix_arr[4].as_f64().unwrap_or(0.0),
1883                    f: matrix_arr[5].as_f64().unwrap_or(0.0),
1884                };
1885                self.state.ctm = m.concat(&self.state.ctm);
1886            }
1887        }
1888
1889        // Parse and execute the form's content stream into temp pixmap
1890        let ops = parse_content_stream(data).map_err(|e| RenderError::Core(e))?;
1891        let _ = self.execute_ops(&ops, page);
1892
1893        // Restore state
1894        if let Some(s) = self.state_stack.pop() {
1895            self.state = s;
1896        }
1897
1898        // Swap back: temp_pixmap now has the group content, device has original
1899        std::mem::swap(&mut self.device.pixmap, &mut temp_pixmap);
1900        self.device.clip_mask = saved_clip;
1901
1902        // Composite the group result onto the main pixmap
1903        let alpha = self.state.fill_alpha as f32;
1904        let bm = self.blend_mode();
1905        self.device.draw_pixmap(
1906            &temp_pixmap.as_ref(),
1907            Transform::identity(),
1908            alpha,
1909            bm,
1910        );
1911
1912        Ok(())
1913    }
1914
1915    /// Render a form xobject directly (without transparency group handling).
1916    /// Used as fallback when temp pixmap creation fails.
1917    fn render_form_xobject_direct(
1918        &mut self,
1919        dict: &PdfDict,
1920        data: &[u8],
1921        page: &PageInfo,
1922    ) -> Result<()> {
1923        self.state_stack.push(self.state.clone());
1924
1925        if let Some(matrix_arr) = dict.get_array(b"Matrix") {
1926            if matrix_arr.len() >= 6 {
1927                let m = Matrix {
1928                    a: matrix_arr[0].as_f64().unwrap_or(1.0),
1929                    b: matrix_arr[1].as_f64().unwrap_or(0.0),
1930                    c: matrix_arr[2].as_f64().unwrap_or(0.0),
1931                    d: matrix_arr[3].as_f64().unwrap_or(1.0),
1932                    e: matrix_arr[4].as_f64().unwrap_or(0.0),
1933                    f: matrix_arr[5].as_f64().unwrap_or(0.0),
1934                };
1935                self.state.ctm = m.concat(&self.state.ctm);
1936            }
1937        }
1938
1939        let ops = parse_content_stream(data).map_err(|e| RenderError::Core(e))?;
1940        self.execute_ops(&ops, page)?;
1941
1942        if let Some(s) = self.state_stack.pop() {
1943            self.state = s;
1944        }
1945
1946        Ok(())
1947    }
1948
1949    fn apply_extgstate(&mut self, name: &[u8], page: &PageInfo) -> Result<()> {
1950        let resources_obj = match &page.resources_ref {
1951            Some(obj) => self.resolve_object(obj)?,
1952            None => return Ok(()),
1953        };
1954
1955        let resources_dict = match &resources_obj {
1956            PdfObject::Dict(d) => d.clone(),
1957            _ => return Ok(()),
1958        };
1959
1960        let extgstate_dict_obj = match resources_dict.get(b"ExtGState") {
1961            Some(PdfObject::Dict(d)) => PdfObject::Dict(d.clone()),
1962            Some(PdfObject::Reference(r)) => {
1963                let r = r.clone();
1964                self.doc.resolve(&r)?
1965            }
1966            _ => return Ok(()),
1967        };
1968
1969        let extgstate_dict = match &extgstate_dict_obj {
1970            PdfObject::Dict(d) => d,
1971            _ => return Ok(()),
1972        };
1973
1974        let gs_obj = match extgstate_dict.get(name) {
1975            Some(PdfObject::Reference(r)) => {
1976                let r = r.clone();
1977                self.doc.resolve(&r)?
1978            }
1979            Some(other) => other.clone(),
1980            None => return Ok(()),
1981        };
1982
1983        if let PdfObject::Dict(gs_dict) = &gs_obj {
1984            if let Some(lw) = gs_dict.get(b"LW").and_then(|o| o.as_f64()) {
1985                self.state.line_width = lw;
1986            }
1987            if let Some(lc) = gs_dict.get(b"LC").and_then(|o| o.as_i64()) {
1988                self.state.line_cap = match lc {
1989                    1 => LineCap::Round,
1990                    2 => LineCap::Square,
1991                    _ => LineCap::Butt,
1992                };
1993            }
1994            if let Some(lj) = gs_dict.get(b"LJ").and_then(|o| o.as_i64()) {
1995                self.state.line_join = match lj {
1996                    1 => LineJoin::Round,
1997                    2 => LineJoin::Bevel,
1998                    _ => LineJoin::Miter,
1999                };
2000            }
2001            if let Some(ml) = gs_dict.get(b"ML").and_then(|o| o.as_f64()) {
2002                self.state.miter_limit = ml;
2003            }
2004            // Fill alpha (ca)
2005            if let Some(a) = gs_dict.get(b"ca").and_then(|o| o.as_f64()) {
2006                self.state.fill_alpha = a;
2007            }
2008            // Stroke alpha (CA)
2009            if let Some(a) = gs_dict.get(b"CA").and_then(|o| o.as_f64()) {
2010                self.state.stroke_alpha = a;
2011            }
2012            // Blend mode (BM)
2013            if let Some(bm_name) = gs_dict.get(b"BM").and_then(|o| o.as_name()) {
2014                self.state.blend_mode = PdfBlendMode::from_name(bm_name);
2015            }
2016
2017            // Soft mask (SMask)
2018            match gs_dict.get(b"SMask") {
2019                Some(PdfObject::Name(n)) if n == b"None" => {
2020                    self.state.soft_mask = None;
2021                }
2022                Some(PdfObject::Dict(smask_dict)) => {
2023                    let _ = self.apply_soft_mask(smask_dict.clone(), page);
2024                }
2025                _ => {}
2026            }
2027        }
2028
2029        Ok(())
2030    }
2031
2032    /// Resolve and render a soft mask from an SMask dictionary.
2033    fn apply_soft_mask(&mut self, smask_dict: PdfDict, page: &PageInfo) -> Result<()> {
2034        // /S: Luminosity or Alpha
2035        let subtype = match smask_dict.get_name(b"S") {
2036            Some(b"Luminosity") => SoftMaskSubtype::Luminosity,
2037            Some(b"Alpha") => SoftMaskSubtype::Alpha,
2038            _ => return Ok(()), // unsupported subtype
2039        };
2040
2041        // /G: form XObject reference for the mask
2042        let form_obj = match smask_dict.get(b"G") {
2043            Some(PdfObject::Reference(r)) => {
2044                let r = r.clone();
2045                match self.doc.resolve(&r) {
2046                    Ok(obj) => obj,
2047                    Err(_) => return Ok(()),
2048                }
2049            }
2050            Some(other) => other.clone(),
2051            None => return Ok(()),
2052        };
2053
2054        let (form_dict, form_data) = match form_obj {
2055            PdfObject::Stream { dict, data } => {
2056                match self.doc.decode_stream(&dict, &data) {
2057                    Ok(decoded) => (dict, decoded),
2058                    Err(_) => return Ok(()),
2059                }
2060            }
2061            _ => return Ok(()),
2062        };
2063
2064        let w = self.device.pixmap.width();
2065        let h = self.device.pixmap.height();
2066
2067        // Create a temporary pixmap to render the mask form into
2068        let mut mask_pixmap = match Pixmap::new(w, h) {
2069            Some(p) => p,
2070            None => return Ok(()),
2071        };
2072
2073        // For luminosity masks, initialize to white background if /BC is specified
2074        if subtype == SoftMaskSubtype::Luminosity {
2075            // Default backdrop: black (which means mask = 0, fully transparent)
2076            // Some PDFs specify /BC (backdrop color) but we use black as default
2077            mask_pixmap.fill(tiny_skia::Color::BLACK);
2078        }
2079
2080        // Swap in the mask pixmap for rendering
2081        let saved_clip = self.device.clip_mask.take();
2082        std::mem::swap(&mut self.device.pixmap, &mut mask_pixmap);
2083
2084        // Save and reset state for mask rendering
2085        self.state_stack.push(self.state.clone());
2086
2087        // Apply form matrix if present
2088        if let Some(matrix_arr) = form_dict.get_array(b"Matrix") {
2089            if matrix_arr.len() >= 6 {
2090                let m = Matrix {
2091                    a: matrix_arr[0].as_f64().unwrap_or(1.0),
2092                    b: matrix_arr[1].as_f64().unwrap_or(0.0),
2093                    c: matrix_arr[2].as_f64().unwrap_or(0.0),
2094                    d: matrix_arr[3].as_f64().unwrap_or(1.0),
2095                    e: matrix_arr[4].as_f64().unwrap_or(0.0),
2096                    f: matrix_arr[5].as_f64().unwrap_or(0.0),
2097                };
2098                self.state.ctm = m.concat(&self.state.ctm);
2099            }
2100        }
2101
2102        // Render the mask form
2103        if let Ok(ops) = parse_content_stream(&form_data) {
2104            let _ = self.execute_ops(&ops, page);
2105        }
2106
2107        // Restore state
2108        if let Some(s) = self.state_stack.pop() {
2109            self.state = s;
2110        }
2111
2112        // Swap back
2113        std::mem::swap(&mut self.device.pixmap, &mut mask_pixmap);
2114        self.device.clip_mask = saved_clip;
2115
2116        // Convert the rendered mask pixmap to a tiny_skia::Mask
2117        if let Some(mask) = self.pixmap_to_mask(&mask_pixmap, subtype) {
2118            self.state.soft_mask = Some(SoftMask { mask, subtype });
2119        }
2120
2121        Ok(())
2122    }
2123
2124    /// Convert a rendered pixmap to a Mask based on luminosity or alpha.
2125    fn pixmap_to_mask(&self, pixmap: &Pixmap, subtype: SoftMaskSubtype) -> Option<Mask> {
2126        let w = pixmap.width();
2127        let h = pixmap.height();
2128        let mut mask = Mask::new(w, h)?;
2129        let mask_data = mask.data_mut();
2130        let src_data = pixmap.data();
2131
2132        for i in 0..(w * h) as usize {
2133            let idx = i * 4;
2134            if idx + 3 >= src_data.len() {
2135                break;
2136            }
2137            let value = match subtype {
2138                SoftMaskSubtype::Luminosity => {
2139                    // Convert RGB to luminosity: 0.2126*R + 0.7152*G + 0.0722*B
2140                    let r = src_data[idx] as f32 / 255.0;
2141                    let g = src_data[idx + 1] as f32 / 255.0;
2142                    let b = src_data[idx + 2] as f32 / 255.0;
2143                    (0.2126 * r + 0.7152 * g + 0.0722 * b).clamp(0.0, 1.0) * 255.0
2144                }
2145                SoftMaskSubtype::Alpha => {
2146                    src_data[idx + 3] as f32
2147                }
2148            };
2149            mask_data[i] = value as u8;
2150        }
2151
2152        Some(mask)
2153    }
2154
2155    // --- Pattern rendering ---
2156
2157    /// Resolve a pattern from page resources.
2158    fn resolve_pattern(&mut self, name: &[u8], page: &PageInfo) -> Result<Option<PdfObject>> {
2159        let resources_obj = match &page.resources_ref {
2160            Some(obj) => self.resolve_object(obj)?,
2161            None => return Ok(None),
2162        };
2163
2164        let resources_dict = match &resources_obj {
2165            PdfObject::Dict(d) => d.clone(),
2166            _ => return Ok(None),
2167        };
2168
2169        let pattern_dict_obj = match resources_dict.get(b"Pattern") {
2170            Some(PdfObject::Dict(d)) => PdfObject::Dict(d.clone()),
2171            Some(PdfObject::Reference(r)) => {
2172                let r = r.clone();
2173                self.doc.resolve(&r)?
2174            }
2175            _ => return Ok(None),
2176        };
2177
2178        let pattern_dict = match &pattern_dict_obj {
2179            PdfObject::Dict(d) => d,
2180            _ => return Ok(None),
2181        };
2182
2183        match pattern_dict.get(name) {
2184            Some(PdfObject::Reference(r)) => {
2185                let r = r.clone();
2186                Ok(Some(self.doc.resolve(&r)?))
2187            }
2188            Some(other) => Ok(Some(other.clone())),
2189            None => Ok(None),
2190        }
2191    }
2192
2193    /// Render a tiling pattern cell and return the pixmap.
2194    fn render_tiling_pattern(
2195        &mut self,
2196        pattern_dict: &PdfDict,
2197        pattern_data: &[u8],
2198        page: &PageInfo,
2199    ) -> Result<Option<Pixmap>> {
2200        let xstep = pattern_dict
2201            .get(b"XStep")
2202            .and_then(|o| o.as_f64())
2203            .unwrap_or(1.0)
2204            .abs();
2205        let ystep = pattern_dict
2206            .get(b"YStep")
2207            .and_then(|o| o.as_f64())
2208            .unwrap_or(1.0)
2209            .abs();
2210
2211        if xstep < 1.0 || ystep < 1.0 {
2212            return Ok(None);
2213        }
2214
2215        // Compute effective scale from CTM + page transform
2216        let effective = self.state.ctm.concat(&self.page_transform);
2217        let sx = (effective.a * effective.a + effective.b * effective.b)
2218            .sqrt()
2219            .abs();
2220        let sy = (effective.c * effective.c + effective.d * effective.d)
2221            .sqrt()
2222            .abs();
2223
2224        // Pattern cell size in device pixels
2225        let cell_w = (xstep * sx).ceil().max(1.0).min(2048.0) as u32;
2226        let cell_h = (ystep * sy).ceil().max(1.0).min(2048.0) as u32;
2227
2228        let mut cell_pixmap = match Pixmap::new(cell_w, cell_h) {
2229            Some(p) => p,
2230            None => return Ok(None),
2231        };
2232
2233        // Pattern matrix (from pattern dict)
2234        let pattern_matrix = if let Some(matrix_arr) = pattern_dict.get_array(b"Matrix") {
2235            if matrix_arr.len() >= 6 {
2236                Matrix {
2237                    a: matrix_arr[0].as_f64().unwrap_or(1.0),
2238                    b: matrix_arr[1].as_f64().unwrap_or(0.0),
2239                    c: matrix_arr[2].as_f64().unwrap_or(0.0),
2240                    d: matrix_arr[3].as_f64().unwrap_or(1.0),
2241                    e: matrix_arr[4].as_f64().unwrap_or(0.0),
2242                    f: matrix_arr[5].as_f64().unwrap_or(0.0),
2243                }
2244            } else {
2245                Matrix::identity()
2246            }
2247        } else {
2248            Matrix::identity()
2249        };
2250
2251        // Build the transform for rendering the pattern cell:
2252        // Pattern coords -> pattern matrix -> scale to device pixels
2253        let scale_to_device = Matrix::scale(
2254            cell_w as f64 / xstep,
2255            cell_h as f64 / ystep,
2256        );
2257        let cell_transform = pattern_matrix.concat(&scale_to_device);
2258
2259        // Swap in the cell pixmap
2260        let saved_clip = self.device.clip_mask.take();
2261        std::mem::swap(&mut self.device.pixmap, &mut cell_pixmap);
2262
2263        // Save state for pattern rendering
2264        self.state_stack.push(self.state.clone());
2265        let saved_page_transform = self.page_transform;
2266
2267        // Set up state for rendering into the cell
2268        self.state.ctm = Matrix::identity();
2269        self.page_transform = cell_transform;
2270
2271        // Render the pattern content stream
2272        if let Ok(ops) = parse_content_stream(pattern_data) {
2273            let _ = self.execute_ops(&ops, page);
2274        }
2275
2276        // Restore state
2277        self.page_transform = saved_page_transform;
2278        if let Some(s) = self.state_stack.pop() {
2279            self.state = s;
2280        }
2281
2282        // Swap back
2283        std::mem::swap(&mut self.device.pixmap, &mut cell_pixmap);
2284        self.device.clip_mask = saved_clip;
2285
2286        Ok(Some(cell_pixmap))
2287    }
2288
2289    /// Try to fill a path using the current fill pattern, if one is set.
2290    /// Returns true if pattern fill was performed.
2291    fn try_fill_with_pattern(&mut self, path: &tiny_skia::Path, rule: FillRule, page: &PageInfo) -> bool {
2292        let pattern_name = match &self.state.fill_pattern_name {
2293            Some(name) => name.clone(),
2294            None => return false,
2295        };
2296
2297        if let Ok(Some(pattern_pixmap)) = self.resolve_and_render_pattern(&pattern_name, page) {
2298            let transform = self.effective_transform();
2299            let bm = self.blend_mode();
2300            self.device.fill_path_with_pattern(
2301                path,
2302                rule,
2303                transform,
2304                &pattern_pixmap.as_ref(),
2305                Transform::identity(),
2306                bm,
2307            );
2308            true
2309        } else {
2310            false
2311        }
2312    }
2313
2314    /// Try to stroke a path using the current stroke pattern, if one is set.
2315    /// Returns true if pattern stroke was performed.
2316    fn try_stroke_with_pattern(&mut self, path: &tiny_skia::Path, page: &PageInfo) -> bool {
2317        let pattern_name = match &self.state.stroke_pattern_name {
2318            Some(name) => name.clone(),
2319            None => return false,
2320        };
2321
2322        if let Ok(Some(pattern_pixmap)) = self.resolve_and_render_pattern(&pattern_name, page) {
2323            let transform = self.effective_transform();
2324            let bm = self.blend_mode();
2325            self.device.stroke_path_with_pattern(
2326                path,
2327                transform,
2328                &self.state,
2329                &pattern_pixmap.as_ref(),
2330                Transform::identity(),
2331                bm,
2332            );
2333            true
2334        } else {
2335            false
2336        }
2337    }
2338
2339    /// Resolve a pattern by name and render it. Handles both tiling and shading patterns.
2340    fn resolve_and_render_pattern(
2341        &mut self,
2342        name: &[u8],
2343        page: &PageInfo,
2344    ) -> Result<Option<Pixmap>> {
2345        let pattern_obj = match self.resolve_pattern(name, page)? {
2346            Some(obj) => obj,
2347            None => return Ok(None),
2348        };
2349
2350        match &pattern_obj {
2351            PdfObject::Stream { dict, data } => {
2352                let pattern_type = dict.get_i64(b"PatternType").unwrap_or(0);
2353                match pattern_type {
2354                    1 => {
2355                        // Tiling pattern
2356                        let decoded = match self.doc.decode_stream(dict, data) {
2357                            Ok(d) => d,
2358                            Err(_) => return Ok(None),
2359                        };
2360                        let dict = dict.clone();
2361                        self.render_tiling_pattern(&dict, &decoded, page)
2362                    }
2363                    2 => {
2364                        // Shading pattern: render shading into a temp pixmap
2365                        self.render_shading_pattern(dict, page)
2366                    }
2367                    _ => Ok(None),
2368                }
2369            }
2370            PdfObject::Dict(dict) => {
2371                let pattern_type = dict.get_i64(b"PatternType").unwrap_or(0);
2372                if pattern_type == 2 {
2373                    self.render_shading_pattern(dict, page)
2374                } else {
2375                    Ok(None)
2376                }
2377            }
2378            _ => Ok(None),
2379        }
2380    }
2381
2382    /// Render a shading pattern (PatternType 2) into a pixmap.
2383    fn render_shading_pattern(
2384        &mut self,
2385        pattern_dict: &PdfDict,
2386        _page: &PageInfo,
2387    ) -> Result<Option<Pixmap>> {
2388        // Get the shading dict from the pattern
2389        let shading_obj = match pattern_dict.get(b"Shading") {
2390            Some(PdfObject::Reference(r)) => {
2391                let r = r.clone();
2392                match self.doc.resolve(&r) {
2393                    Ok(obj) => obj,
2394                    Err(_) => return Ok(None),
2395                }
2396            }
2397            Some(other) => other.clone(),
2398            None => return Ok(None),
2399        };
2400
2401        // Extract dict and optional stream data
2402        let (shading_dict, stream_data) = match &shading_obj {
2403            PdfObject::Dict(d) => (d.clone(), None),
2404            PdfObject::Stream { dict, data } => {
2405                let decoded = self.doc.decode_stream(dict, data).ok();
2406                (dict.clone(), decoded)
2407            }
2408            _ => return Ok(None),
2409        };
2410
2411        // Resolve function references within the shading dict
2412        let mut resolved_shading = shading_dict;
2413        if let Some(PdfObject::Reference(func_ref)) = resolved_shading.get(b"Function").cloned() {
2414            if let Ok(func_obj) = self.doc.resolve(&func_ref) {
2415                resolved_shading.insert(b"Function".to_vec(), func_obj);
2416            }
2417        }
2418
2419        let w = self.device.pixmap.width();
2420        let h = self.device.pixmap.height();
2421
2422        let mut shading_pixmap = match Pixmap::new(w, h) {
2423            Some(p) => p,
2424            None => return Ok(None),
2425        };
2426
2427        // Pattern matrix
2428        let pattern_matrix = if let Some(matrix_arr) = pattern_dict.get_array(b"Matrix") {
2429            if matrix_arr.len() >= 6 {
2430                Matrix {
2431                    a: matrix_arr[0].as_f64().unwrap_or(1.0),
2432                    b: matrix_arr[1].as_f64().unwrap_or(0.0),
2433                    c: matrix_arr[2].as_f64().unwrap_or(0.0),
2434                    d: matrix_arr[3].as_f64().unwrap_or(1.0),
2435                    e: matrix_arr[4].as_f64().unwrap_or(0.0),
2436                    f: matrix_arr[5].as_f64().unwrap_or(0.0),
2437                }
2438            } else {
2439                Matrix::identity()
2440            }
2441        } else {
2442            Matrix::identity()
2443        };
2444
2445        // The effective CTM for the shading is pattern_matrix * CTM
2446        let effective_ctm = pattern_matrix.concat(&self.state.ctm);
2447
2448        let clip = self.device.clip_mask.as_ref();
2449        crate::shading::render_shading(
2450            &mut shading_pixmap,
2451            &resolved_shading,
2452            &effective_ctm,
2453            &self.page_transform,
2454            clip,
2455            stream_data.as_deref(),
2456        );
2457
2458        Ok(Some(shading_pixmap))
2459    }
2460}
2461
2462enum XObjectData {
2463    Image { dict: PdfDict, data: Vec<u8> },
2464    Form { dict: PdfDict, data: Vec<u8> },
2465}
2466
2467/// Convert decoded image data to RGBA bytes.
2468fn image_to_rgba(img: &image::DecodedImage) -> Vec<u8> {
2469    let pixel_count = (img.width * img.height) as usize;
2470    let mut rgba = vec![255u8; pixel_count * 4];
2471
2472    match img.components {
2473        1 => {
2474            // Grayscale
2475            for i in 0..pixel_count.min(img.data.len()) {
2476                let g = img.data[i];
2477                rgba[i * 4] = g;
2478                rgba[i * 4 + 1] = g;
2479                rgba[i * 4 + 2] = g;
2480            }
2481        }
2482        3 => {
2483            // RGB
2484            for i in 0..pixel_count.min(img.data.len() / 3) {
2485                rgba[i * 4] = img.data[i * 3];
2486                rgba[i * 4 + 1] = img.data[i * 3 + 1];
2487                rgba[i * 4 + 2] = img.data[i * 3 + 2];
2488            }
2489        }
2490        4 => {
2491            // CMYK → RGB
2492            for i in 0..pixel_count.min(img.data.len() / 4) {
2493                let c = img.data[i * 4] as f64 / 255.0;
2494                let m = img.data[i * 4 + 1] as f64 / 255.0;
2495                let y = img.data[i * 4 + 2] as f64 / 255.0;
2496                let k = img.data[i * 4 + 3] as f64 / 255.0;
2497                rgba[i * 4] = ((1.0 - c) * (1.0 - k) * 255.0) as u8;
2498                rgba[i * 4 + 1] = ((1.0 - m) * (1.0 - k) * 255.0) as u8;
2499                rgba[i * 4 + 2] = ((1.0 - y) * (1.0 - k) * 255.0) as u8;
2500            }
2501        }
2502        _ => {
2503            // Fill with black
2504            for i in 0..pixel_count {
2505                rgba[i * 4] = 0;
2506                rgba[i * 4 + 1] = 0;
2507                rgba[i * 4 + 2] = 0;
2508            }
2509        }
2510    }
2511
2512    rgba
2513}
2514
2515fn cs_from_name(name: &[u8]) -> ColorSpace {
2516    match name {
2517        b"DeviceGray" | b"G" => ColorSpace::DeviceGray,
2518        b"DeviceRGB" | b"RGB" => ColorSpace::DeviceRGB,
2519        b"DeviceCMYK" | b"CMYK" => ColorSpace::DeviceCMYK,
2520        _ => ColorSpace::DeviceRGB, // fallback
2521    }
2522}
2523
2524/// Parse a CIDToGIDMap stream: 2 bytes (big-endian) per CID entry.
2525fn parse_cid_gid_stream(data: &[u8]) -> Vec<u16> {
2526    data.chunks(2)
2527        .map(|c| {
2528            if c.len() == 2 {
2529                ((c[0] as u16) << 8) | (c[1] as u16)
2530            } else {
2531                c[0] as u16
2532            }
2533        })
2534        .collect()
2535}
2536
2537/// Get f64 from operands at index.
2538fn f(operands: &[Operand], idx: usize) -> f64 {
2539    operands.get(idx).and_then(|o| o.as_f64()).unwrap_or(0.0)
2540}