Skip to main content

docling_pdf/render/
content.rs

1//! The content-stream interpreter (ISO 32000-1, 8–9): graphics state, path
2//! construction and painting, clipping, text, XObjects, inline images,
3//! shadings and patterns, ExtGState, and the widget annotations' appearance
4//! streams — drawn onto a tiny-skia canvas in device space.
5//!
6//! Where docling-parse's renderer makes a choice, this one follows it: a
7//! stroke is at least one device pixel wide and a `0 w` hairline is one
8//! pixel; a line width scales by `sqrt(|det CTM|)`; ExtGState alpha and
9//! blend modes apply, soft masks and transparency groups do not (the group
10//! is drawn straight onto the page); dash patterns are drawn (docling-parse
11//! drops them); only Widget annotations are rendered, from their `/AP /N`
12//! appearance; a glyph with no face at all leaves a thin blue box.
13
14use std::cell::RefCell;
15use std::collections::HashMap;
16use std::rc::Rc;
17
18use lopdf::{Dictionary, Document, Object, ObjectId};
19use tiny_skia::{
20    BlendMode, FillRule, FilterQuality, GradientStop, LineCap, LineJoin, Mask, Paint, Path,
21    PathBuilder, Pixmap, PixmapPaint, Point, SpreadMode, Stroke, StrokeDash, Transform,
22};
23
24use super::color::{to_u8, CmykCache, ColorSpace};
25use super::font::{FontCache, LoadedFont};
26use super::function::Function;
27use super::geom::{Box2, Mat};
28use super::image;
29use super::objects::{
30    as_dict, as_stream, deref, get, get_bool, get_dict, get_int, get_name, get_num, name, num,
31    nums, resource,
32};
33
34const MAX_OPS: usize = 2_000_000;
35const MAX_DEPTH: usize = 14;
36
37/// A mesh vertex: device point and RGB.
38type Vertex = ((f64, f64), [f64; 3]);
39/// A flat-shaded mesh triangle (types 4/5): corners and their mean colour.
40type Triangle = ([(f64, f64); 3], [f64; 3]);
41/// A flat-shaded Coons/tensor patch (types 6/7): its boundary and mean colour.
42type Patch = (Vec<(f64, f64)>, [f64; 3]);
43
44#[derive(Clone)]
45struct TextState {
46    font: Option<Rc<LoadedFont>>,
47    size: f64,
48    char_spacing: f64,
49    word_spacing: f64,
50    hscale: f64,
51    leading: f64,
52    rise: f64,
53    render_mode: i64,
54}
55
56#[derive(Clone)]
57struct GState {
58    ctm: Mat,
59    fill_cs: ColorSpace,
60    stroke_cs: ColorSpace,
61    /// Device RGB, `None` = paints nothing (Separation /None, an unresolved pattern).
62    fill_rgb: Option<[f64; 3]>,
63    stroke_rgb: Option<[f64; 3]>,
64    /// A pattern selected with `scn` / `SCN` (`/Pattern` colour space).
65    fill_pattern: Option<Object>,
66    stroke_pattern: Option<Object>,
67    line_width: f64,
68    line_cap: LineCap,
69    line_join: LineJoin,
70    miter_limit: f64,
71    dash: Option<(Vec<f64>, f64)>,
72    fill_alpha: f64,
73    stroke_alpha: f64,
74    blend: BlendMode,
75    /// The clip: an axis-aligned rectangle (device space) when nothing else
76    /// has been intersected, and a coverage mask once a shape has.
77    clip_box: Box2,
78    clip_mask: Option<Rc<Mask>>,
79    text: TextState,
80    /// An uncoloured tiling pattern's cell: colour operators are ignored.
81    fixed_color: bool,
82    /// The constant alpha and blend mode in force at the `Do` of the
83    /// enclosing transparency group(s), applied to everything drawn inside
84    /// (docling-parse's `enter_transparency_group`: the group is not
85    /// composited as a unit, its parameters are pushed down instead).
86    group_alpha: f64,
87    group_blend: BlendMode,
88}
89
90impl GState {
91    fn eff_fill_alpha(&self) -> f64 {
92        self.fill_alpha * self.group_alpha
93    }
94    fn eff_stroke_alpha(&self) -> f64 {
95        self.stroke_alpha * self.group_alpha
96    }
97    /// Content that does not blend on its own inherits the group's mode.
98    fn eff_blend(&self) -> BlendMode {
99        if self.blend == BlendMode::SourceOver {
100            self.group_blend
101        } else {
102            self.blend
103        }
104    }
105}
106
107/// The caches one document's renders share: parsed fonts, the CMYK
108/// conversion table and decoded image samples. The pipeline renders every
109/// page twice (the layout scale and the OCR scale), so decoding a page's
110/// photographs once pays for itself; the store is bounded by
111/// [`IMAGE_BUDGET`] bytes and cleared wholesale when it overflows.
112pub struct Shared {
113    fonts: RefCell<FontCache>,
114    cmyk: RefCell<CmykCache>,
115    images: RefCell<ImageStore>,
116    /// docling-parse's `bitmap_target_pixels_per_unit`: the resolution, in
117    /// pixels per PDF unit, a JPEG needs to be decoded at for the page it is
118    /// drawn on ([`image::codec_reduction_shift`]). docling renders with
119    /// `render_scale` 1.0 and re-renders the same decoders at the model
120    /// scales, so 1.0 is the pipeline's value whatever the canvas scale;
121    /// `0.0` disables the reduced decode (every image at full size).
122    bitmap_hint: f64,
123}
124
125impl Default for Shared {
126    fn default() -> Self {
127        Shared {
128            fonts: RefCell::default(),
129            cmyk: RefCell::default(),
130            images: RefCell::default(),
131            bitmap_hint: 1.0,
132        }
133    }
134}
135
136impl Shared {
137    /// Caches for a renderer decoding its JPEGs for `bitmap_hint` pixels per
138    /// PDF unit (see the field).
139    pub fn with_bitmap_hint(bitmap_hint: f64) -> Shared {
140        Shared {
141            bitmap_hint,
142            ..Shared::default()
143        }
144    }
145
146    /// The renderer's default `bitmap_target_pixels_per_unit`.
147    pub fn bitmap_hint(&self) -> f64 {
148        self.bitmap_hint
149    }
150}
151
152/// Decoded image samples per source stream (`image::load`'s result), by
153/// (object id, reduction shift).
154#[derive(Default)]
155struct ImageStore {
156    by_id: HashMap<(ObjectId, u32), Rc<image::LoadedImage>>,
157    bytes: usize,
158}
159
160/// The decoded-sample budget of one document's image store (256 MB): a
161/// scanned book's pages are ~8 MB each at 300 dpi, so the two-scale render
162/// of a page always hits, and a document that overflows merely re-decodes.
163const IMAGE_BUDGET: usize = 256 << 20;
164
165impl Shared {
166    fn font(&self, doc: &Document, obj: &Object) -> Option<Rc<LoadedFont>> {
167        self.fonts.borrow_mut().get(doc, obj)
168    }
169
170    /// `image::load` memoised by object id (inline and direct images are
171    /// decoded every time).
172    fn load_image(
173        &self,
174        doc: &Document,
175        stream: &lopdf::Stream,
176        id: Option<ObjectId>,
177        res: Option<&Dictionary>,
178        reduction_shift: u32,
179    ) -> Result<Rc<image::LoadedImage>, String> {
180        if let Some(id) = id {
181            if let Some(img) = self.images.borrow().by_id.get(&(id, reduction_shift)) {
182                return Ok(img.clone());
183            }
184        }
185        let img = Rc::new(image::load(doc, stream, res, reduction_shift)?);
186        if let Some(id) = id {
187            let mut store = self.images.borrow_mut();
188            let bytes = img.bytes();
189            if store.bytes + bytes > IMAGE_BUDGET {
190                store.by_id.clear();
191                store.bytes = 0;
192            }
193            if bytes <= IMAGE_BUDGET {
194                store.bytes += bytes;
195                store.by_id.insert((id, reduction_shift), img.clone());
196            }
197        }
198        Ok(img)
199    }
200}
201
202pub struct Interp<'a> {
203    doc: &'a Document,
204    canvas: Pixmap,
205    /// Device box of the whole canvas.
206    device: Box2,
207    /// Per-document caches, shared by every render of the document.
208    shared: Rc<Shared>,
209    /// Images rasterized for this canvas, by (object id, reduction, fill
210    /// colour for stencils).
211    images: HashMap<(ObjectId, u32, u32, [u8; 3]), Rc<image::DecodedImage>>,
212    /// Rectangle clip masks by rounded coordinates (many text objects share one).
213    rect_masks: HashMap<[i32; 4], Rc<Mask>>,
214    /// Shape clip masks by (path hash, parent mask, rule): a page that sets
215    /// the same rounded frame before each of its figures builds it once.
216    shape_masks: HashMap<(u64, usize, bool), Rc<Mask>>,
217    ops: usize,
218    /// Fonts that drew a blue fallback box at least once (warned once).
219    warned_no_face: bool,
220    /// Type 3 glyph procedures being run, to stop recursion.
221    type3_depth: usize,
222    /// The page's base CTM: pattern space (8.7.3.1).
223    base: Mat,
224    /// `bitmap_target_pixels_per_unit` for this render (see
225    /// [`Shared::bitmap_hint`]): the model-input renders take docling's 1.0,
226    /// a bitmap kept for OCR or handed to a caller takes 0.0 (full size).
227    bitmap_hint: f64,
228}
229
230/// One painted sub-path being built (user space, transformed at paint time).
231struct PathState {
232    builder: PathBuilder,
233    /// The current point and the sub-path start in user space.
234    current: Option<(f64, f64)>,
235    start: Option<(f64, f64)>,
236    /// `re` operands seen so far, when the path is nothing but rectangles.
237    rects: Vec<[f64; 4]>,
238    only_rects: bool,
239    pending_clip: Option<FillRule>,
240    empty: bool,
241}
242
243impl PathState {
244    fn new() -> PathState {
245        PathState {
246            builder: PathBuilder::new(),
247            current: None,
248            start: None,
249            rects: Vec::new(),
250            only_rects: true,
251            pending_clip: None,
252            empty: true,
253        }
254    }
255}
256
257impl<'a> Interp<'a> {
258    /// A white canvas of `width` × `height` device pixels.
259    pub fn new(
260        doc: &'a Document,
261        width: u32,
262        height: u32,
263        shared: Rc<Shared>,
264        bitmap_hint: f64,
265    ) -> Option<Interp<'a>> {
266        let mut canvas = Pixmap::new(width, height)?;
267        canvas.fill(tiny_skia::Color::WHITE);
268        Some(Interp {
269            doc,
270            canvas,
271            device: Box2::new(0.0, 0.0, f64::from(width), f64::from(height)),
272            shared,
273            bitmap_hint,
274            images: HashMap::new(),
275            rect_masks: HashMap::new(),
276            shape_masks: HashMap::new(),
277            ops: 0,
278            warned_no_face: false,
279            type3_depth: 0,
280            base: Mat::IDENTITY,
281        })
282    }
283
284    pub fn into_canvas(self) -> Pixmap {
285        self.canvas
286    }
287
288    fn initial_state(&self, ctm: Mat) -> GState {
289        GState {
290            ctm,
291            fill_cs: ColorSpace::DeviceGray,
292            stroke_cs: ColorSpace::DeviceGray,
293            fill_rgb: Some([0.0, 0.0, 0.0]),
294            stroke_rgb: Some([0.0, 0.0, 0.0]),
295            fill_pattern: None,
296            stroke_pattern: None,
297            line_width: 1.0,
298            line_cap: LineCap::Butt,
299            line_join: LineJoin::Miter,
300            miter_limit: 10.0,
301            dash: None,
302            fill_alpha: 1.0,
303            stroke_alpha: 1.0,
304            blend: BlendMode::SourceOver,
305            clip_box: self.device,
306            clip_mask: None,
307            text: TextState {
308                font: None,
309                size: 0.0,
310                char_spacing: 0.0,
311                word_spacing: 0.0,
312                hscale: 1.0,
313                leading: 0.0,
314                rise: 0.0,
315                render_mode: 0,
316            },
317            fixed_color: false,
318            group_alpha: 1.0,
319            group_blend: BlendMode::SourceOver,
320        }
321    }
322
323    /// Draw a page's content under `base` (user space → device).
324    pub fn run_page(&mut self, content: &[u8], resources: Option<&Dictionary>, base: Mat) {
325        self.base = base;
326        let st = self.initial_state(base);
327        self.run(content, resources, st, 0);
328    }
329
330    /// Draw the page's Widget annotations (`/AP /N`, honouring `/AS` and the
331    /// Hidden / NoView flags) — what docling-parse renders of the annotations.
332    pub fn run_widgets(&mut self, page: &Dictionary, base: Mat) {
333        let doc = self.doc;
334        let Some(Object::Array(annots)) = get(doc, page, b"Annots") else {
335            return;
336        };
337        for a in annots {
338            let Some(ad) = as_dict(doc, a) else { continue };
339            if get_name(doc, ad, b"Subtype") != Some(b"Widget") {
340                continue;
341            }
342            let flags = get_int(doc, ad, b"F").unwrap_or(0);
343            if flags & 2 != 0 || flags & 32 != 0 {
344                continue;
345            }
346            let Some(rect) = get(doc, ad, b"Rect")
347                .and_then(|o| nums(doc, o))
348                .filter(|r| r.len() == 4)
349            else {
350                continue;
351            };
352            let Some(ap) = get_dict(doc, ad, b"AP") else {
353                continue;
354            };
355            let Some(n) = get(doc, ap, b"N") else {
356                continue;
357            };
358            let stream = match n {
359                Object::Stream(s) => Some(s),
360                Object::Dictionary(states) => {
361                    let as_name = get_name(doc, ad, b"AS");
362                    match as_name {
363                        Some(st) => states.get(st).ok().and_then(|o| as_stream(doc, o)),
364                        None if states.len() == 1 => {
365                            states.iter().next().and_then(|(_, o)| as_stream(doc, o))
366                        }
367                        None => None,
368                    }
369                }
370                _ => None,
371            };
372            let Some(stream) = stream else { continue };
373            // 12.5.5: the form's BBox through its Matrix, fitted to Rect.
374            let bbox = get(doc, &stream.dict, b"BBox")
375                .and_then(|o| nums(doc, o))
376                .filter(|b| b.len() == 4);
377            let matrix = get(doc, &stream.dict, b"Matrix")
378                .and_then(|o| nums(doc, o))
379                .and_then(|v| Mat::from_slice(&v))
380                .unwrap_or(Mat::IDENTITY);
381            let rx0 = rect[0].min(rect[2]);
382            let ry0 = rect[1].min(rect[3]);
383            let rx1 = rect[0].max(rect[2]);
384            let ry1 = rect[1].max(rect[3]);
385            let a = match bbox {
386                Some(b) => {
387                    let tb = Box2::new(b[0], b[1], b[2], b[3]).transformed(matrix);
388                    let sx = if tb.width() > 1e-9 {
389                        (rx1 - rx0) / tb.width()
390                    } else {
391                        1.0
392                    };
393                    let sy = if tb.height() > 1e-9 {
394                        (ry1 - ry0) / tb.height()
395                    } else {
396                        1.0
397                    };
398                    Mat::new(sx, 0.0, 0.0, sy, rx0 - tb.x0 * sx, ry0 - tb.y0 * sy)
399                }
400                None => Mat::IDENTITY,
401            };
402            let mut st = self.initial_state(a.then(base));
403            st.clip_box = self.device;
404            self.draw_form(stream, &st, None, 1);
405        }
406    }
407
408    fn run(&mut self, content: &[u8], resources: Option<&Dictionary>, init: GState, depth: usize) {
409        if depth > MAX_DEPTH {
410            return;
411        }
412        // Inline images and `d0`/`d1` go through the pre-pass (lopdf's
413        // lexer drops or splits them).
414        let prepared = super::prepass::prepare(content, self.doc, resources);
415        let Ok(ops) = lopdf::content::Content::decode(&prepared.content) else {
416            return;
417        };
418        let inline = &prepared.inline;
419        let doc = self.doc;
420        let mut stack: Vec<GState> = Vec::new();
421        let mut st = init.clone();
422        let mut path = PathState::new();
423        // Text object state.
424        let mut tm = Mat::IDENTITY;
425        let mut tlm = Mat::IDENTITY;
426        let mut compat = 0usize;
427        for op in &ops.operations {
428            self.ops += 1;
429            if self.ops > MAX_OPS {
430                return;
431            }
432            let args = &op.operands;
433            let n = |i: usize| args.get(i).and_then(num).unwrap_or(0.0);
434            match op.operator.as_str() {
435                // --- graphics state ------------------------------------------
436                "q" => {
437                    stack.push(st.clone());
438                    if stack.len() > 64 {
439                        stack.remove(0);
440                    }
441                }
442                "Q" => {
443                    if let Some(s) = stack.pop() {
444                        st = s;
445                    }
446                }
447                "cm" => {
448                    if args.len() >= 6 {
449                        let v: Vec<f64> = (0..6).map(n).collect();
450                        if let Some(m) = Mat::from_slice(&v) {
451                            st.ctm = m.then(st.ctm);
452                        }
453                    }
454                }
455                "w" => st.line_width = n(0).abs(),
456                "J" => {
457                    st.line_cap = match n(0) as i64 {
458                        1 => LineCap::Round,
459                        2 => LineCap::Square,
460                        _ => LineCap::Butt,
461                    }
462                }
463                "j" => {
464                    st.line_join = match n(0) as i64 {
465                        1 => LineJoin::Round,
466                        2 => LineJoin::Bevel,
467                        _ => LineJoin::Miter,
468                    }
469                }
470                "M" => st.miter_limit = n(0).max(1.0),
471                "d" => {
472                    let arr: Vec<f64> = args
473                        .first()
474                        .and_then(|o| nums(doc, o))
475                        .unwrap_or_default()
476                        .into_iter()
477                        .filter(|v| v.is_finite() && *v >= 0.0)
478                        .collect();
479                    st.dash = if arr.is_empty() || arr.iter().all(|v| *v <= 0.0) {
480                        None
481                    } else {
482                        Some((arr, n(1)))
483                    };
484                }
485                "ri" | "i" => {}
486                "gs" => {
487                    if let Some(gs) = args
488                        .first()
489                        .and_then(name)
490                        .and_then(|nm| resource(doc, resources, b"ExtGState", nm))
491                        .and_then(|o| as_dict(doc, o))
492                    {
493                        self.apply_extgstate(&mut st, gs, resources);
494                    }
495                }
496                // --- path construction ---------------------------------------
497                "m" => {
498                    if args.len() >= 2 {
499                        let (x, y) = (n(0), n(1));
500                        path.builder.move_to(x as f32, y as f32);
501                        path.current = Some((x, y));
502                        path.start = Some((x, y));
503                        path.only_rects = false;
504                        path.empty = false;
505                    }
506                }
507                "l" => {
508                    if args.len() >= 2 {
509                        let (x, y) = (n(0), n(1));
510                        if path.current.is_none() {
511                            path.builder.move_to(x as f32, y as f32);
512                            path.start = Some((x, y));
513                        } else {
514                            path.builder.line_to(x as f32, y as f32);
515                        }
516                        path.current = Some((x, y));
517                        path.only_rects = false;
518                        path.empty = false;
519                    }
520                }
521                "c" | "v" | "y" => {
522                    let cur = path.current.unwrap_or((n(0), n(1)));
523                    let (x1, y1, x2, y2, x3, y3) = match op.operator.as_str() {
524                        "c" if args.len() >= 6 => (n(0), n(1), n(2), n(3), n(4), n(5)),
525                        "v" if args.len() >= 4 => (cur.0, cur.1, n(0), n(1), n(2), n(3)),
526                        "y" if args.len() >= 4 => (n(0), n(1), n(2), n(3), n(2), n(3)),
527                        _ => continue,
528                    };
529                    if path.current.is_none() {
530                        path.builder.move_to(cur.0 as f32, cur.1 as f32);
531                        path.start = Some(cur);
532                    }
533                    path.builder.cubic_to(
534                        x1 as f32, y1 as f32, x2 as f32, y2 as f32, x3 as f32, y3 as f32,
535                    );
536                    path.current = Some((x3, y3));
537                    path.only_rects = false;
538                    path.empty = false;
539                }
540                "h" => {
541                    if path.current.is_some() {
542                        path.builder.close();
543                        path.current = path.start;
544                    }
545                }
546                "re" => {
547                    if args.len() >= 4 {
548                        let (x, y, w, h) = (n(0), n(1), n(2), n(3));
549                        path.builder.move_to(x as f32, y as f32);
550                        path.builder.line_to((x + w) as f32, y as f32);
551                        path.builder.line_to((x + w) as f32, (y + h) as f32);
552                        path.builder.line_to(x as f32, (y + h) as f32);
553                        path.builder.close();
554                        path.current = Some((x, y));
555                        path.start = Some((x, y));
556                        path.rects.push([x, y, w, h]);
557                        path.empty = false;
558                    }
559                }
560                // --- path painting -------------------------------------------
561                "S" | "s" | "f" | "F" | "f*" | "B" | "B*" | "b" | "b*" | "n" => {
562                    let o = op.operator.as_str();
563                    if matches!(o, "s" | "b" | "b*") && path.current.is_some() {
564                        path.builder.close();
565                    }
566                    let rule = if o.ends_with('*') {
567                        FillRule::EvenOdd
568                    } else {
569                        FillRule::Winding
570                    };
571                    let fill = matches!(o, "f" | "F" | "f*" | "B" | "B*" | "b" | "b*");
572                    let stroke = matches!(o, "S" | "s" | "B" | "B*" | "b" | "b*");
573                    let mut finished = std::mem::replace(&mut path, PathState::new());
574                    let user_path =
575                        std::mem::replace(&mut finished.builder, PathBuilder::new()).finish();
576                    if let Some(up) = &user_path {
577                        if let Some(dev) = up.clone().transform(st.ctm.to_ts()) {
578                            if fill {
579                                self.fill_device_path(&dev, rule, &st, resources, false);
580                            }
581                            if stroke {
582                                self.stroke_device_path(&dev, up, &st, resources);
583                            }
584                        }
585                    }
586                    if let Some(clip_rule) = finished.pending_clip {
587                        crate::timing::timed("render.apply_clip", || {
588                            self.apply_clip(&mut st, user_path.as_ref(), &finished, clip_rule)
589                        });
590                    }
591                }
592                "W" => path.pending_clip = Some(FillRule::Winding),
593                "W*" => path.pending_clip = Some(FillRule::EvenOdd),
594                // --- colour ----------------------------------------------------
595                "CS" | "cs" => {
596                    if st.fixed_color {
597                        continue;
598                    }
599                    let cs = args
600                        .first()
601                        .and_then(|o| ColorSpace::parse(doc, o, resources))
602                        .unwrap_or(ColorSpace::DeviceGray);
603                    let initial = cs.initial();
604                    let rgb = match cs {
605                        ColorSpace::Pattern(_) => None,
606                        _ => cs.to_rgb(&initial),
607                    };
608                    if op.operator == "cs" {
609                        st.fill_cs = cs;
610                        st.fill_rgb = rgb;
611                        st.fill_pattern = None;
612                    } else {
613                        st.stroke_cs = cs;
614                        st.stroke_rgb = rgb;
615                        st.stroke_pattern = None;
616                    }
617                }
618                "SC" | "SCN" | "sc" | "scn" => {
619                    if st.fixed_color {
620                        continue;
621                    }
622                    let is_fill = op.operator.starts_with('s');
623                    let cs = if is_fill { &st.fill_cs } else { &st.stroke_cs };
624                    let (rgb, pattern) = match cs {
625                        ColorSpace::Pattern(base) => {
626                            let pat_name = args.last().and_then(name);
627                            let pat = pat_name
628                                .and_then(|nm| resource(doc, resources, b"Pattern", nm))
629                                .cloned();
630                            // An uncoloured pattern's colour operands in the base space.
631                            let comps: Vec<f64> = args.iter().filter_map(num).collect();
632                            let rgb = match (base, comps.is_empty()) {
633                                (Some(b), false) => b.to_rgb(&comps),
634                                _ => Some([0.5, 0.5, 0.5]),
635                            };
636                            (rgb, pat)
637                        }
638                        cs => {
639                            let comps: Vec<f64> = args.iter().filter_map(num).collect();
640                            if comps.is_empty() {
641                                continue;
642                            }
643                            (cs.to_rgb(&comps), None)
644                        }
645                    };
646                    if is_fill {
647                        st.fill_rgb = rgb;
648                        st.fill_pattern = pattern;
649                    } else {
650                        st.stroke_rgb = rgb;
651                        st.stroke_pattern = pattern;
652                    }
653                }
654                "g" | "G" | "rg" | "RG" | "k" | "K" => {
655                    if st.fixed_color {
656                        continue;
657                    }
658                    let cs = match op.operator.as_str() {
659                        "g" | "G" => ColorSpace::DeviceGray,
660                        "rg" | "RG" => ColorSpace::DeviceRGB,
661                        _ => ColorSpace::DeviceCMYK,
662                    };
663                    let comps: Vec<f64> = args.iter().filter_map(num).collect();
664                    let rgb = cs.to_rgb(&comps);
665                    if op
666                        .operator
667                        .chars()
668                        .next()
669                        .is_some_and(|c| c.is_ascii_lowercase())
670                    {
671                        st.fill_cs = cs;
672                        st.fill_rgb = rgb;
673                        st.fill_pattern = None;
674                    } else {
675                        st.stroke_cs = cs;
676                        st.stroke_rgb = rgb;
677                        st.stroke_pattern = None;
678                    }
679                }
680                // --- text --------------------------------------------------------
681                "BT" => {
682                    tm = Mat::IDENTITY;
683                    tlm = Mat::IDENTITY;
684                }
685                "ET" => {}
686                "Tc" => st.text.char_spacing = n(0),
687                "Tw" => st.text.word_spacing = n(0),
688                "Tz" => st.text.hscale = n(0) / 100.0,
689                "TL" => st.text.leading = n(0),
690                "Ts" => st.text.rise = n(0),
691                "Tr" => st.text.render_mode = n(0) as i64,
692                "Tf" => {
693                    st.text.size = n(1);
694                    st.text.font = args
695                        .first()
696                        .and_then(name)
697                        .and_then(|nm| resource(doc, resources, b"Font", nm))
698                        .and_then(|o| self.shared.font(doc, o));
699                    if st.text.font.is_none() {
700                        // A missing font resource: Helvetica-like fallback so the
701                        // text still lands on the page.
702                        let mut d = Dictionary::new();
703                        d.set("BaseFont", Object::Name(b"Helvetica".to_vec()));
704                        st.text.font = self.shared.font(doc, &Object::Dictionary(d));
705                    }
706                }
707                "Td" => {
708                    tlm = Mat::translate(n(0), n(1)).then(tlm);
709                    tm = tlm;
710                }
711                "TD" => {
712                    st.text.leading = -n(1);
713                    tlm = Mat::translate(n(0), n(1)).then(tlm);
714                    tm = tlm;
715                }
716                "Tm" => {
717                    if args.len() >= 6 {
718                        let v: Vec<f64> = (0..6).map(n).collect();
719                        if let Some(m) = Mat::from_slice(&v) {
720                            tlm = m;
721                            tm = tlm;
722                        }
723                    }
724                }
725                "T*" => {
726                    tlm = Mat::translate(0.0, -st.text.leading).then(tlm);
727                    tm = tlm;
728                }
729                "Tj" | "'" | "\"" => {
730                    if op.operator != "Tj" {
731                        if op.operator == "\"" && args.len() >= 3 {
732                            st.text.word_spacing = n(0);
733                            st.text.char_spacing = n(1);
734                        }
735                        tlm = Mat::translate(0.0, -st.text.leading).then(tlm);
736                        tm = tlm;
737                    }
738                    if let Some(Object::String(s, _)) = args.last() {
739                        self.show_text(s, &mut tm, &st, resources, depth);
740                    }
741                }
742                "TJ" => {
743                    if let Some(Object::Array(items)) = args.first() {
744                        for it in items {
745                            match it {
746                                Object::String(s, _) => {
747                                    self.show_text(s, &mut tm, &st, resources, depth)
748                                }
749                                o => {
750                                    if let Some(adj) = num(o) {
751                                        let tx = -adj / 1000.0 * st.text.size * st.text.hscale;
752                                        let vertical =
753                                            st.text.font.as_ref().is_some_and(|f| f.vertical);
754                                        tm = if vertical {
755                                            Mat::translate(0.0, -adj / 1000.0 * st.text.size)
756                                                .then(tm)
757                                        } else {
758                                            Mat::translate(tx, 0.0).then(tm)
759                                        };
760                                    }
761                                }
762                            }
763                        }
764                    }
765                }
766                // Type 3 glyph metrics (rewritten by the pre-pass); a `d1`
767                // glyph is a stencil — its colour operators are ignored and
768                // the text fill colour paints it.
769                "dZero" => {}
770                "dOne" => st.fixed_color = true,
771                // --- XObjects, images, shadings -----------------------------------
772                "Do" => {
773                    if let Some(xobj) = args
774                        .first()
775                        .and_then(name)
776                        .and_then(|nm| resource(doc, resources, b"XObject", nm))
777                    {
778                        let id = match xobj {
779                            Object::Reference(id) => Some(*id),
780                            _ => None,
781                        };
782                        if let Some(stream) = as_stream(doc, xobj) {
783                            match get_name(doc, &stream.dict, b"Subtype") {
784                                Some(b"Image") => crate::timing::timed("render.image", || {
785                                    self.draw_image(stream, id, &st, resources)
786                                }),
787                                Some(b"Form") => self.draw_form(stream, &st, resources, depth + 1),
788                                Some(b"PS") => {}
789                                _ => {
790                                    if stream.dict.has(b"BBox") {
791                                        self.draw_form(stream, &st, resources, depth + 1);
792                                    } else if stream.dict.has(b"Width") {
793                                        self.draw_image(stream, id, &st, resources);
794                                    }
795                                }
796                            }
797                        }
798                    }
799                }
800                "BIX" => {
801                    // An inline image the pre-pass cut out (`/I<n> BIX`).
802                    if let Some(s) = args
803                        .first()
804                        .and_then(super::prepass::inline_index)
805                        .and_then(|n| inline.get(n))
806                    {
807                        self.draw_image(s, None, &st, resources);
808                    }
809                }
810                "sh" => {
811                    if let Some(sh) = args
812                        .first()
813                        .and_then(name)
814                        .and_then(|nm| resource(doc, resources, b"Shading", nm))
815                    {
816                        let sh_obj = deref(doc, sh).clone();
817                        crate::timing::timed("render.sh", || {
818                            self.paint_shading(
819                                &sh_obj,
820                                st.ctm,
821                                &st,
822                                None,
823                                st.eff_fill_alpha(),
824                                true,
825                            )
826                        });
827                    }
828                }
829                "BX" => compat += 1,
830                "EX" => compat = compat.saturating_sub(1),
831                "BMC" | "BDC" | "EMC" | "MP" | "DP" => {}
832                _ => {}
833            }
834        }
835        let _ = compat;
836    }
837
838    // ---------------------------------------------------------------------
839    // ExtGState
840    // ---------------------------------------------------------------------
841
842    fn apply_extgstate(
843        &mut self,
844        st: &mut GState,
845        gs: &Dictionary,
846        resources: Option<&Dictionary>,
847    ) {
848        let doc = self.doc;
849        for (k, v) in gs.iter() {
850            let v = deref(doc, v);
851            match k.as_slice() {
852                b"LW" => {
853                    if let Some(w) = num(v) {
854                        st.line_width = w.abs();
855                    }
856                }
857                b"LC" => {
858                    st.line_cap = match num(v).unwrap_or(0.0) as i64 {
859                        1 => LineCap::Round,
860                        2 => LineCap::Square,
861                        _ => LineCap::Butt,
862                    }
863                }
864                b"LJ" => {
865                    st.line_join = match num(v).unwrap_or(0.0) as i64 {
866                        1 => LineJoin::Round,
867                        2 => LineJoin::Bevel,
868                        _ => LineJoin::Miter,
869                    }
870                }
871                b"ML" => {
872                    if let Some(m) = num(v) {
873                        st.miter_limit = m.max(1.0);
874                    }
875                }
876                b"D" => {
877                    if let Object::Array(a) = v {
878                        let arr: Vec<f64> =
879                            a.first().and_then(|o| nums(doc, o)).unwrap_or_default();
880                        let phase = a.get(1).and_then(|o| num(deref(doc, o))).unwrap_or(0.0);
881                        st.dash = if arr.is_empty() || arr.iter().all(|x| *x <= 0.0) {
882                            None
883                        } else {
884                            Some((arr, phase))
885                        };
886                    }
887                }
888                b"CA" => {
889                    if let Some(a) = num(v) {
890                        st.stroke_alpha = a.clamp(0.0, 1.0);
891                    }
892                }
893                b"ca" => {
894                    if let Some(a) = num(v) {
895                        st.fill_alpha = a.clamp(0.0, 1.0);
896                    }
897                }
898                b"BM" => {
899                    let nm = match v {
900                        Object::Name(n) => Some(n.as_slice()),
901                        Object::Array(a) => a.first().and_then(name),
902                        _ => None,
903                    };
904                    st.blend = match nm {
905                        Some(b"Multiply") => BlendMode::Multiply,
906                        Some(b"Screen") => BlendMode::Screen,
907                        Some(b"Overlay") => BlendMode::Overlay,
908                        Some(b"Darken") => BlendMode::Darken,
909                        Some(b"Lighten") => BlendMode::Lighten,
910                        Some(b"ColorDodge") => BlendMode::ColorDodge,
911                        Some(b"ColorBurn") => BlendMode::ColorBurn,
912                        Some(b"HardLight") => BlendMode::HardLight,
913                        Some(b"SoftLight") => BlendMode::SoftLight,
914                        Some(b"Difference") => BlendMode::Difference,
915                        Some(b"Exclusion") => BlendMode::Exclusion,
916                        Some(b"Hue") => BlendMode::Hue,
917                        Some(b"Saturation") => BlendMode::Saturation,
918                        Some(b"Color") => BlendMode::Color,
919                        Some(b"Luminosity") => BlendMode::Luminosity,
920                        _ => BlendMode::SourceOver,
921                    };
922                }
923                b"Font" => {
924                    if let Object::Array(a) = v {
925                        if let (Some(f), Some(sz)) =
926                            (a.first(), a.get(1).and_then(|o| num(deref(doc, o))))
927                        {
928                            if let Some(font) = self.shared.font(doc, f) {
929                                st.text.font = Some(font);
930                                st.text.size = sz;
931                            }
932                        }
933                    }
934                }
935                _ => {}
936            }
937        }
938        let _ = resources;
939    }
940
941    // ---------------------------------------------------------------------
942    // Clipping
943    // ---------------------------------------------------------------------
944
945    /// Intersect the clip with the just-painted path.
946    fn apply_clip(
947        &mut self,
948        st: &mut GState,
949        user_path: Option<&Path>,
950        ps: &PathState,
951        rule: FillRule,
952    ) {
953        // An empty path clips everything.
954        let Some(up) = user_path else {
955            st.clip_box = Box2::new(0.0, 0.0, 0.0, 0.0);
956            return;
957        };
958        // A single axis-aligned rectangle (the common `re W n`) stays a box.
959        if ps.only_rects && ps.rects.len() == 1 && st.ctm.b.abs() < 1e-9 && st.ctm.c.abs() < 1e-9 {
960            let r = ps.rects[0];
961            let b = Box2::new(r[0], r[1], r[0] + r[2], r[1] + r[3]).transformed(st.ctm);
962            st.clip_box = st.clip_box.intersect(b);
963            if st.clip_box.is_empty() {
964                st.clip_box = Box2::new(0.0, 0.0, 0.0, 0.0);
965            }
966            return;
967        }
968        let Some(dev) = up.clone().transform(st.ctm.to_ts()) else {
969            return;
970        };
971        let bb = dev.bounds();
972        let pb = Box2::new(
973            f64::from(bb.left()),
974            f64::from(bb.top()),
975            f64::from(bb.right()),
976            f64::from(bb.bottom()),
977        );
978        // A rectangle drawn with m/l/l/l/h (what most producers emit) is a
979        // box clip too: every vertex sits on a corner of the bounding box.
980        if device_path_is_rect(&dev) {
981            st.clip_box = st.clip_box.intersect(pb);
982            if st.clip_box.is_empty() {
983                st.clip_box = Box2::new(0.0, 0.0, 0.0, 0.0);
984            }
985            return;
986        }
987        let new_box = st.clip_box.intersect(pb);
988        if new_box.is_empty() {
989            st.clip_box = Box2::new(0.0, 0.0, 0.0, 0.0);
990            st.clip_mask = None;
991            return;
992        }
993        let parent_ptr = st
994            .clip_mask
995            .as_ref()
996            .map(|m| Rc::as_ptr(m) as usize)
997            .unwrap_or(0);
998        let key = (
999            path_hash(&dev, st.clip_box),
1000            parent_ptr,
1001            rule == FillRule::EvenOdd,
1002        );
1003        if let Some(m) = self.shape_masks.get(&key) {
1004            st.clip_mask = Some(m.clone());
1005            st.clip_box = new_box;
1006            return;
1007        }
1008        let mask = crate::timing::timed("render.clip_mask", || {
1009            shape_mask(
1010                self.canvas.width(),
1011                self.canvas.height(),
1012                &dev,
1013                rule,
1014                new_box,
1015                st.clip_mask.as_deref(),
1016            )
1017        });
1018        let Some(mask) = mask else { return };
1019        let mask = Rc::new(mask);
1020        if self.shape_masks.len() > 256 {
1021            self.shape_masks.clear();
1022        }
1023        self.shape_masks.insert(key, mask.clone());
1024        st.clip_mask = Some(mask);
1025        st.clip_box = new_box;
1026    }
1027
1028    /// The mask to draw with: the shape mask, or a cached one for the
1029    /// rectangle clip — none when the clip is the whole canvas, or when
1030    /// `bounds` (the shape's device box) lies inside the rectangle anyway,
1031    /// which is nearly every glyph of a clipped text block.
1032    fn clip_mask_for(&mut self, st: &GState, bounds: Option<tiny_skia::Rect>) -> Option<Rc<Mask>> {
1033        if let Some(m) = &st.clip_mask {
1034            return Some(m.clone());
1035        }
1036        let cb = st.clip_box;
1037        if cb.x0 <= 0.0 && cb.y0 <= 0.0 && cb.x1 >= self.device.x1 && cb.y1 >= self.device.y1 {
1038            return None;
1039        }
1040        if let Some(b) = bounds {
1041            if f64::from(b.left()) >= cb.x0 - 1e-6
1042                && f64::from(b.top()) >= cb.y0 - 1e-6
1043                && f64::from(b.right()) <= cb.x1 + 1e-6
1044                && f64::from(b.bottom()) <= cb.y1 + 1e-6
1045            {
1046                return None;
1047            }
1048        }
1049        let key = [
1050            (cb.x0 * 4.0).round() as i32,
1051            (cb.y0 * 4.0).round() as i32,
1052            (cb.x1 * 4.0).round() as i32,
1053            (cb.y1 * 4.0).round() as i32,
1054        ];
1055        if let Some(m) = self.rect_masks.get(&key) {
1056            return Some(m.clone());
1057        }
1058        let mut m = Mask::new(self.canvas.width(), self.canvas.height())?;
1059        let r = tiny_skia::Rect::from_ltrb(cb.x0 as f32, cb.y0 as f32, cb.x1 as f32, cb.y1 as f32)?;
1060        m.fill_path(
1061            &PathBuilder::from_rect(r),
1062            FillRule::Winding,
1063            true,
1064            Transform::identity(),
1065        );
1066        let m = Rc::new(m);
1067        if self.rect_masks.len() > 64 {
1068            self.rect_masks.clear();
1069        }
1070        self.rect_masks.insert(key, m.clone());
1071        Some(m)
1072    }
1073
1074    fn visible(&self, st: &GState, dev_bounds: tiny_skia::Rect) -> bool {
1075        let b = Box2::new(
1076            f64::from(dev_bounds.left()),
1077            f64::from(dev_bounds.top()),
1078            f64::from(dev_bounds.right()),
1079            f64::from(dev_bounds.bottom()),
1080        );
1081        !st.clip_box.intersect(b).is_empty()
1082            || (b.width() == 0.0 || b.height() == 0.0) && !st.clip_box.is_empty()
1083    }
1084
1085    // ---------------------------------------------------------------------
1086    // Painting
1087    // ---------------------------------------------------------------------
1088
1089    fn paint_for(rgb: [f64; 3], alpha: f64, blend: BlendMode) -> Paint<'static> {
1090        let mut p = Paint::default();
1091        p.set_color_rgba8(to_u8(rgb[0]), to_u8(rgb[1]), to_u8(rgb[2]), to_u8(alpha));
1092        p.anti_alias = true;
1093        p.blend_mode = blend;
1094        p
1095    }
1096
1097    /// Fill a device-space path with the fill colour or pattern.
1098    fn fill_device_path(
1099        &mut self,
1100        dev: &Path,
1101        rule: FillRule,
1102        st: &GState,
1103        resources: Option<&Dictionary>,
1104        is_text: bool,
1105    ) {
1106        if st.eff_fill_alpha() <= 1.0 / 512.0 || !self.visible(st, dev.bounds()) {
1107            return;
1108        }
1109        if let Some(pat) = st.fill_pattern.clone() {
1110            crate::timing::timed("render.pattern_fill", || {
1111                self.fill_with_pattern(dev, rule, &pat, st, resources)
1112            });
1113            return;
1114        }
1115        let Some(rgb) = st.fill_rgb else { return };
1116        let paint = Self::paint_for(rgb, st.eff_fill_alpha(), st.eff_blend());
1117        let mask = self.clip_mask_for(st, Some(dev.bounds()));
1118        let stage = if is_text {
1119            "render.glyph_fill"
1120        } else {
1121            "render.path_fill"
1122        };
1123        crate::timing::timed(stage, || {
1124            self.canvas
1125                .fill_path(dev, &paint, rule, Transform::identity(), mask.as_deref())
1126        });
1127    }
1128
1129    fn stroke_of(&self, st: &GState) -> Stroke {
1130        let scale = st.ctm.mean_scale();
1131        let width = (st.line_width * scale).max(1.0);
1132        let dash = st.dash.as_ref().and_then(|(arr, phase)| {
1133            let mut a: Vec<f32> = arr.iter().map(|v| (v * scale) as f32).collect();
1134            if a.len() % 2 == 1 {
1135                let c = a.clone();
1136                a.extend(c);
1137            }
1138            if a.iter().all(|v| *v <= 0.0) || a.iter().any(|v| !v.is_finite()) {
1139                return None;
1140            }
1141            StrokeDash::new(a, (phase * scale) as f32)
1142        });
1143        Stroke {
1144            width: width as f32,
1145            miter_limit: st.miter_limit as f32,
1146            line_cap: st.line_cap,
1147            line_join: st.line_join,
1148            dash,
1149        }
1150    }
1151
1152    fn stroke_device_path(
1153        &mut self,
1154        dev: &Path,
1155        _user: &Path,
1156        st: &GState,
1157        resources: Option<&Dictionary>,
1158    ) {
1159        if st.eff_stroke_alpha() <= 1.0 / 512.0 {
1160            return;
1161        }
1162        let stroke = self.stroke_of(st);
1163        let bb = dev.bounds();
1164        let grow = stroke.width;
1165        let Some(bb) = tiny_skia::Rect::from_ltrb(
1166            bb.left() - grow,
1167            bb.top() - grow,
1168            bb.right() + grow,
1169            bb.bottom() + grow,
1170        ) else {
1171            return;
1172        };
1173        if !self.visible(st, bb) {
1174            return;
1175        }
1176        if let Some(pat) = st.stroke_pattern.clone() {
1177            // Stroke with a pattern: the stroked outline, filled by the pattern.
1178            if let Some(outline) = dev.stroke(&stroke, 1.0) {
1179                self.fill_with_pattern(&outline, FillRule::Winding, &pat, st, resources);
1180            }
1181            return;
1182        }
1183        let Some(rgb) = st.stroke_rgb else { return };
1184        let paint = Self::paint_for(rgb, st.eff_stroke_alpha(), st.eff_blend());
1185        let mask = self.clip_mask_for(st, Some(bb));
1186        crate::timing::timed("render.stroke", || {
1187            self.canvas
1188                .stroke_path(dev, &paint, &stroke, Transform::identity(), mask.as_deref())
1189        });
1190    }
1191
1192    // ---------------------------------------------------------------------
1193    // Text
1194    // ---------------------------------------------------------------------
1195
1196    fn show_text(
1197        &mut self,
1198        bytes: &[u8],
1199        tm: &mut Mat,
1200        st: &GState,
1201        resources: Option<&Dictionary>,
1202        depth: usize,
1203    ) {
1204        let Some(font) = st.text.font.clone() else {
1205            return;
1206        };
1207        let ts = &st.text;
1208        let mode = ts.render_mode;
1209        let invisible = mode == 3 || mode == 7;
1210        let do_fill = matches!(mode, 0 | 2 | 4 | 6);
1211        let do_stroke = matches!(mode, 1 | 2 | 5 | 6);
1212        for (code, cid, single_byte) in font.decode(bytes) {
1213            let w0 = font.advance(code, cid);
1214            let trm = Mat::new(ts.size * ts.hscale, 0.0, 0.0, ts.size, 0.0, ts.rise)
1215                .then(*tm)
1216                .then(st.ctm);
1217            if !invisible {
1218                if let Some(t3) = &font.type3 {
1219                    self.draw_type3_glyph(&font, t3, code, trm, st, resources, depth);
1220                } else if let Some(glyph) = font.glyph(code, cid) {
1221                    if let Some(dev) = (*glyph).clone().transform(trm.to_ts()) {
1222                        if do_fill {
1223                            self.fill_device_path(&dev, FillRule::Winding, st, resources, true);
1224                        }
1225                        if do_stroke {
1226                            let mut sst = st.clone();
1227                            // Text stroke width is in user space, not text space.
1228                            sst.line_width = st.line_width;
1229                            self.stroke_device_path(&dev, &dev, &sst, resources);
1230                        }
1231                    }
1232                } else if !font.has_program() && (do_fill || do_stroke) {
1233                    self.draw_missing_glyph_box(w0, trm, st);
1234                }
1235            }
1236            let mut adv = if font.vertical {
1237                // Vertical writing: advance down by the default 1 em.
1238                0.0
1239            } else {
1240                w0 * ts.size + ts.char_spacing
1241            };
1242            if single_byte && code == 32 {
1243                adv += ts.word_spacing;
1244            }
1245            if font.vertical {
1246                let ty = -(ts.size
1247                    + ts.char_spacing
1248                    + if single_byte && code == 32 {
1249                        ts.word_spacing
1250                    } else {
1251                        0.0
1252                    });
1253                *tm = Mat::translate(0.0, ty).then(*tm);
1254            } else {
1255                *tm = Mat::translate(adv * ts.hscale, 0.0).then(*tm);
1256            }
1257        }
1258    }
1259
1260    /// docling-parse's fallback for a cell no face could draw: a 0.5 px
1261    /// outline in `#1070C0` around the glyph box.
1262    fn draw_missing_glyph_box(&mut self, w0: f64, trm: Mat, st: &GState) {
1263        if !self.warned_no_face {
1264            self.warned_no_face = true;
1265            eprintln!(
1266                "docling-pdf: no fallback font face on this host — text without an embedded font is drawn as boxes \
1267                 (install Liberation/DejaVu fonts, or point DOCLING_RS_FONT_DIRS at a font directory)"
1268            );
1269        }
1270        let w = if w0 > 0.0 { w0 } else { 0.5 };
1271        let pts = [
1272            trm.apply(0.0, -0.2),
1273            trm.apply(w, -0.2),
1274            trm.apply(w, 0.8),
1275            trm.apply(0.0, 0.8),
1276        ];
1277        let mut pb = PathBuilder::new();
1278        pb.move_to(pts[0].0 as f32, pts[0].1 as f32);
1279        for p in &pts[1..] {
1280            pb.line_to(p.0 as f32, p.1 as f32);
1281        }
1282        pb.close();
1283        let Some(path) = pb.finish() else { return };
1284        let mut paint = Paint::default();
1285        paint.set_color_rgba8(0x10, 0x70, 0xC0, 0xFF);
1286        paint.anti_alias = true;
1287        let stroke = Stroke {
1288            width: 0.5,
1289            ..Stroke::default()
1290        };
1291        let mask = self.clip_mask_for(st, Some(path.bounds()));
1292        self.canvas.stroke_path(
1293            &path,
1294            &paint,
1295            &stroke,
1296            Transform::identity(),
1297            mask.as_deref(),
1298        );
1299    }
1300
1301    #[allow(clippy::too_many_arguments)]
1302    fn draw_type3_glyph(
1303        &mut self,
1304        font: &LoadedFont,
1305        t3: &super::font::Type3,
1306        code: u32,
1307        trm: Mat,
1308        st: &GState,
1309        resources: Option<&Dictionary>,
1310        depth: usize,
1311    ) {
1312        if self.type3_depth > 4 || depth > MAX_DEPTH {
1313            return;
1314        }
1315        let name = font_type3_name(font, code);
1316        let Some(name) = name else { return };
1317        let Some(id) = t3.char_procs.get(&name) else {
1318            return;
1319        };
1320        let Ok(Object::Stream(proc_stream)) = self.doc.get_object(*id) else {
1321            return;
1322        };
1323        let Ok(content) = proc_stream.decompressed_content() else {
1324            return;
1325        };
1326        let mut gst = st.clone();
1327        gst.ctm = t3.font_matrix.then(trm);
1328        // A glyph procedure starts with the text's fill colour; `d1` glyphs
1329        // ignore colour operators (their ink is the fill colour).
1330        let res = t3.resources.as_ref().or(resources);
1331        self.type3_depth += 1;
1332        self.run(&content, res, gst, depth + 1);
1333        self.type3_depth -= 1;
1334    }
1335
1336    // ---------------------------------------------------------------------
1337    // XObjects
1338    // ---------------------------------------------------------------------
1339
1340    fn draw_form(
1341        &mut self,
1342        stream: &lopdf::Stream,
1343        st: &GState,
1344        parent_res: Option<&Dictionary>,
1345        depth: usize,
1346    ) {
1347        if depth > MAX_DEPTH {
1348            return;
1349        }
1350        let doc = self.doc;
1351        let d = &stream.dict;
1352        let mut gst = st.clone();
1353        if get_dict(doc, d, b"Group").is_some() {
1354            // 11.6.6: the group composites as a unit with the alpha and blend
1355            // mode in force at the `Do`; without a group buffer they are
1356            // pushed down onto its contents, and an inner `gs` sets alpha
1357            // relative to them.
1358            gst.group_alpha = st.eff_fill_alpha();
1359            gst.group_blend = st.eff_blend();
1360            gst.fill_alpha = 1.0;
1361            gst.stroke_alpha = 1.0;
1362            gst.blend = BlendMode::SourceOver;
1363        }
1364        if let Some(m) = get(doc, d, b"Matrix")
1365            .and_then(|o| nums(doc, o))
1366            .and_then(|v| Mat::from_slice(&v))
1367        {
1368            gst.ctm = m.then(gst.ctm);
1369        }
1370        if let Some(b) = get(doc, d, b"BBox")
1371            .and_then(|o| nums(doc, o))
1372            .filter(|b| b.len() == 4)
1373        {
1374            // Clip to the BBox in form space.
1375            let mut pb = PathBuilder::new();
1376            let (x0, y0, x1, y1) = (
1377                b[0].min(b[2]),
1378                b[1].min(b[3]),
1379                b[0].max(b[2]),
1380                b[1].max(b[3]),
1381            );
1382            pb.move_to(x0 as f32, y0 as f32);
1383            pb.line_to(x1 as f32, y0 as f32);
1384            pb.line_to(x1 as f32, y1 as f32);
1385            pb.line_to(x0 as f32, y1 as f32);
1386            pb.close();
1387            let ps = PathState {
1388                builder: PathBuilder::new(),
1389                current: None,
1390                start: None,
1391                rects: vec![[x0, y0, x1 - x0, y1 - y0]],
1392                only_rects: true,
1393                pending_clip: None,
1394                empty: false,
1395            };
1396            let up = pb.finish();
1397            self.apply_clip(&mut gst, up.as_ref(), &ps, FillRule::Winding);
1398            if gst.clip_box.is_empty() {
1399                return;
1400            }
1401        }
1402        let res = get_dict(doc, d, b"Resources").or(parent_res);
1403        let Ok(content) = stream.decompressed_content() else {
1404            return;
1405        };
1406        self.run(&content, res, gst, depth);
1407    }
1408
1409    fn draw_image(
1410        &mut self,
1411        stream: &lopdf::Stream,
1412        id: Option<ObjectId>,
1413        st: &GState,
1414        resources: Option<&Dictionary>,
1415    ) {
1416        if st.eff_fill_alpha() <= 1.0 / 512.0 {
1417            return;
1418        }
1419        let doc = self.doc;
1420        // The unit square's device extent decides the reduction factor.
1421        let (bx0, by0, bx1, by1) = st.ctm.unit_bbox();
1422        let dev_box = Box2::new(bx0, by0, bx1, by1);
1423        if st.clip_box.intersect(dev_box).is_empty() {
1424            return;
1425        }
1426        let dst_w = st.ctm.apply_vec(1.0, 0.0);
1427        let dst_h = st.ctm.apply_vec(0.0, 1.0);
1428        let dst_w = dst_w.0.hypot(dst_w.1).max(1.0);
1429        let dst_h = dst_h.0.hypot(dst_h.1).max(1.0);
1430        let target = (dst_w.ceil() as u32, dst_h.ceil() as u32);
1431        let is_mask = get_bool(doc, &stream.dict, b"ImageMask")
1432            .or_else(|| get_bool(doc, &stream.dict, b"IM"))
1433            .unwrap_or(false);
1434        let fill = match (is_mask, st.fill_rgb) {
1435            (true, Some(rgb)) => [to_u8(rgb[0]), to_u8(rgb[1]), to_u8(rgb[2])],
1436            (true, None) => return,
1437            _ => [0, 0, 0],
1438        };
1439        // docling-parse decodes a JPEG no larger than the page needs at its
1440        // bitmap hint (a scan at a quarter for the 72 dpi hint, then blitted
1441        // up onto this canvas): the drawn extent in PDF units against the
1442        // declared size picks the reduced inverse DCT.
1443        let reduction = image::declared_size(doc, &stream.dict)
1444            .map(|(sw, sh)| {
1445                let ux = self.base.a.hypot(self.base.b).max(1e-9);
1446                let uy = self.base.c.hypot(self.base.d).max(1e-9);
1447                image::codec_reduction_shift(
1448                    (bx1 - bx0) / ux,
1449                    (by1 - by0) / uy,
1450                    sw,
1451                    sh,
1452                    self.bitmap_hint,
1453                )
1454            })
1455            .unwrap_or(0);
1456        let key = id.map(|id| (id, target.0, target.1, fill));
1457        let img = match key.and_then(|k| self.images.get(&k).cloned()) {
1458            Some(i) => i,
1459            None => {
1460                let decoded = match crate::timing::timed("render.image_decode", || {
1461                    let loaded = self
1462                        .shared
1463                        .load_image(doc, stream, id, resources, reduction)?;
1464                    let mut cmyk = self.shared.cmyk.borrow_mut();
1465                    image::rasterize(&loaded, fill, Some(target), &mut cmyk)
1466                }) {
1467                    Ok(d) => Rc::new(d),
1468                    Err(e) => {
1469                        docling_core::debug_log!("docling-pdf render: image skipped ({e})");
1470                        return;
1471                    }
1472                };
1473                if let Some(k) = key {
1474                    if self.images.len() > 256 {
1475                        self.images.clear();
1476                    }
1477                    self.images.insert(k, decoded.clone());
1478                }
1479                decoded
1480            }
1481        };
1482        // Image space (w × h, y down) → unit square → device.
1483        let (pw, ph) = (
1484            f64::from(img.pixmap.width()),
1485            f64::from(img.pixmap.height()),
1486        );
1487        docling_core::debug_log!(
1488            "docling-pdf render: image {:?} reduction 1/{} → pixmap {pw}×{ph} (src {}×{}) for {target:?} px",
1489            id,
1490            1u32 << reduction,
1491            img.src_width,
1492            img.src_height
1493        );
1494        let to_unit = Mat::new(1.0 / pw, 0.0, 0.0, -1.0 / ph, 0.0, 1.0);
1495        let m = to_unit.then(st.ctm);
1496        let paint = PixmapPaint {
1497            opacity: st.eff_fill_alpha() as f32,
1498            blend_mode: st.eff_blend(),
1499            quality: if dst_w >= pw && dst_h >= ph && pw * ph < 4.0 {
1500                FilterQuality::Nearest
1501            } else {
1502                FilterQuality::Bilinear
1503            },
1504        };
1505        let img_bounds = tiny_skia::Rect::from_ltrb(
1506            dev_box.x0 as f32,
1507            dev_box.y0 as f32,
1508            dev_box.x1 as f32,
1509            dev_box.y1 as f32,
1510        );
1511        let mask = self.clip_mask_for(st, img_bounds);
1512        self.canvas.draw_pixmap(
1513            0,
1514            0,
1515            img.pixmap.as_ref(),
1516            &paint,
1517            m.to_ts(),
1518            mask.as_deref(),
1519        );
1520    }
1521
1522    // ---------------------------------------------------------------------
1523    // Shadings and patterns
1524    // ---------------------------------------------------------------------
1525
1526    /// Paint a shading dictionary/stream through `matrix` (shading space →
1527    /// device), restricted to `area` (a device path) or to the clip.
1528    fn paint_shading(
1529        &mut self,
1530        sh: &Object,
1531        matrix: Mat,
1532        st: &GState,
1533        area: Option<(&Path, FillRule)>,
1534        alpha: f64,
1535        background: bool,
1536    ) {
1537        let doc = self.doc;
1538        let Some(d) = as_dict(doc, sh) else { return };
1539        let stype = get_int(doc, d, b"ShadingType").unwrap_or(0);
1540        let cs = get(doc, d, b"ColorSpace")
1541            .and_then(|o| ColorSpace::parse(doc, o, None))
1542            .unwrap_or(ColorSpace::DeviceRGB);
1543        let func = d
1544            .get(b"Function")
1545            .ok()
1546            .and_then(|o| Function::parse(doc, o));
1547        let mask = self.clip_mask_for(st, area.map(|(p, _)| p.bounds()));
1548        // The region to paint: the given path, else the clip box.
1549        let region: Path = match area {
1550            Some((p, _)) => p.clone(),
1551            None => {
1552                let cb = st.clip_box;
1553                let Some(r) = tiny_skia::Rect::from_ltrb(
1554                    cb.x0 as f32,
1555                    cb.y0 as f32,
1556                    cb.x1 as f32,
1557                    cb.y1 as f32,
1558                ) else {
1559                    return;
1560                };
1561                PathBuilder::from_rect(r)
1562            }
1563        };
1564        let rule = area.map(|(_, r)| r).unwrap_or(FillRule::Winding);
1565        let _ = background;
1566        match stype {
1567            2 | 3 => {
1568                let Some(coords) = get(doc, d, b"Coords").and_then(|o| nums(doc, o)) else {
1569                    return;
1570                };
1571                let domain = get(doc, d, b"Domain")
1572                    .and_then(|o| nums(doc, o))
1573                    .unwrap_or(vec![0.0, 1.0]);
1574                let extend = get(doc, d, b"Extend")
1575                    .and_then(|o| match o {
1576                        Object::Array(a) => Some(
1577                            a.iter()
1578                                .map(|b| matches!(deref(doc, b), Object::Boolean(true)))
1579                                .collect::<Vec<bool>>(),
1580                        ),
1581                        _ => None,
1582                    })
1583                    .unwrap_or(vec![false, false]);
1584                let (e0, e1) = (
1585                    extend.first().copied().unwrap_or(false),
1586                    extend.get(1).copied().unwrap_or(false),
1587                );
1588                let Some(f) = func.as_ref() else { return };
1589                let stops = sample_stops(f, &cs, &domain, alpha, e0, e1);
1590                if stops.len() < 2 {
1591                    return;
1592                }
1593                let shader = if stype == 2 {
1594                    if coords.len() < 4 {
1595                        return;
1596                    }
1597                    tiny_skia::LinearGradient::new(
1598                        Point::from_xy(coords[0] as f32, coords[1] as f32),
1599                        Point::from_xy(coords[2] as f32, coords[3] as f32),
1600                        stops,
1601                        SpreadMode::Pad,
1602                        matrix.to_ts(),
1603                    )
1604                } else {
1605                    if coords.len() < 6 {
1606                        return;
1607                    }
1608                    tiny_skia::RadialGradient::new(
1609                        Point::from_xy(coords[0] as f32, coords[1] as f32),
1610                        coords[2].max(0.0) as f32,
1611                        Point::from_xy(coords[3] as f32, coords[4] as f32),
1612                        coords[5].max(0.0) as f32,
1613                        stops,
1614                        SpreadMode::Pad,
1615                        matrix.to_ts(),
1616                    )
1617                };
1618                let Some(shader) = shader else { return };
1619                let paint = Paint {
1620                    shader,
1621                    blend_mode: st.eff_blend(),
1622                    anti_alias: true,
1623                    force_hq_pipeline: false,
1624                    colorspace: tiny_skia::ColorSpace::Linear,
1625                };
1626                self.canvas.fill_path(
1627                    &region,
1628                    &paint,
1629                    rule,
1630                    Transform::identity(),
1631                    mask.as_deref(),
1632                );
1633            }
1634            1 => {
1635                // Function-based: sample the domain on a grid, draw as an image.
1636                let domain = get(doc, d, b"Domain")
1637                    .and_then(|o| nums(doc, o))
1638                    .unwrap_or(vec![0.0, 1.0, 0.0, 1.0]);
1639                let fm = get(doc, d, b"Matrix")
1640                    .and_then(|o| nums(doc, o))
1641                    .and_then(|v| Mat::from_slice(&v))
1642                    .unwrap_or(Mat::IDENTITY);
1643                let Some(f) = func.as_ref() else { return };
1644                if domain.len() < 4 {
1645                    return;
1646                }
1647                const N: u32 = 64;
1648                let Some(mut pm) = Pixmap::new(N, N) else {
1649                    return;
1650                };
1651                {
1652                    let px = pm.pixels_mut();
1653                    for j in 0..N {
1654                        for i in 0..N {
1655                            let x = domain[0]
1656                                + (domain[1] - domain[0]) * (f64::from(i) + 0.5) / f64::from(N);
1657                            let y = domain[2]
1658                                + (domain[3] - domain[2]) * (f64::from(j) + 0.5) / f64::from(N);
1659                            let out = f.eval(&[x, y]);
1660                            let rgb = cs.to_rgb(&out).unwrap_or([0.0, 0.0, 0.0]);
1661                            px[(j * N + i) as usize] = tiny_skia::PremultipliedColorU8::from_rgba(
1662                                to_u8(rgb[0]),
1663                                to_u8(rgb[1]),
1664                                to_u8(rgb[2]),
1665                                255,
1666                            )
1667                            .unwrap_or(tiny_skia::PremultipliedColorU8::TRANSPARENT);
1668                        }
1669                    }
1670                }
1671                // pixmap (N×N, y down) → domain rect → shading space → device.
1672                let to_domain = Mat::new(
1673                    (domain[1] - domain[0]) / f64::from(N),
1674                    0.0,
1675                    0.0,
1676                    (domain[3] - domain[2]) / f64::from(N),
1677                    domain[0],
1678                    domain[2],
1679                );
1680                let m = to_domain.then(fm).then(matrix);
1681                let shader = tiny_skia::Pattern::new(
1682                    pm.as_ref(),
1683                    SpreadMode::Pad,
1684                    FilterQuality::Bilinear,
1685                    alpha as f32,
1686                    m.to_ts(),
1687                );
1688                let paint = Paint {
1689                    shader,
1690                    blend_mode: st.eff_blend(),
1691                    anti_alias: true,
1692                    force_hq_pipeline: false,
1693                    colorspace: tiny_skia::ColorSpace::Linear,
1694                };
1695                self.canvas.fill_path(
1696                    &region,
1697                    &paint,
1698                    rule,
1699                    Transform::identity(),
1700                    mask.as_deref(),
1701                );
1702            }
1703            4..=7 => {
1704                let Some(stream) = as_stream(doc, sh) else {
1705                    return;
1706                };
1707                let Ok(data) = stream.decompressed_content() else {
1708                    return;
1709                };
1710                let region_mask = if area.is_some() {
1711                    // Mesh patches are clipped to the fill region through a mask.
1712                    let mut m = match mask.as_deref() {
1713                        Some(m) => m.clone(),
1714                        None => {
1715                            let Some(m) = Mask::new(self.canvas.width(), self.canvas.height())
1716                            else {
1717                                return;
1718                            };
1719                            let mut m = m;
1720                            m.fill_path(
1721                                &PathBuilder::from_rect(
1722                                    tiny_skia::Rect::from_ltrb(
1723                                        0.0,
1724                                        0.0,
1725                                        self.device.x1 as f32,
1726                                        self.device.y1 as f32,
1727                                    )
1728                                    .unwrap(),
1729                                ),
1730                                FillRule::Winding,
1731                                true,
1732                                Transform::identity(),
1733                            );
1734                            m
1735                        }
1736                    };
1737                    m.intersect_path(&region, rule, true, Transform::identity());
1738                    Some(Rc::new(m))
1739                } else {
1740                    mask.clone()
1741                };
1742                self.paint_mesh(
1743                    stype,
1744                    d,
1745                    &data,
1746                    &cs,
1747                    func.as_ref(),
1748                    matrix,
1749                    st,
1750                    region_mask.as_deref(),
1751                    alpha,
1752                );
1753            }
1754            _ => {}
1755        }
1756    }
1757
1758    /// Mesh shadings (types 4–7): each triangle / patch filled flat with the
1759    /// mean of its corner colours — the picture the layout model needs.
1760    #[allow(clippy::too_many_arguments)]
1761    fn paint_mesh(
1762        &mut self,
1763        stype: i64,
1764        d: &Dictionary,
1765        data: &[u8],
1766        cs: &ColorSpace,
1767        func: Option<&Function>,
1768        matrix: Mat,
1769        st: &GState,
1770        mask: Option<&Mask>,
1771        alpha: f64,
1772    ) {
1773        let doc = self.doc;
1774        let bpc = get_int(doc, d, b"BitsPerCoordinate")
1775            .unwrap_or(16)
1776            .clamp(1, 32) as u32;
1777        let bpcomp = get_int(doc, d, b"BitsPerComponent")
1778            .unwrap_or(16)
1779            .clamp(1, 16) as u32;
1780        let bpf = get_int(doc, d, b"BitsPerFlag").unwrap_or(8).clamp(2, 8) as u32;
1781        let decode = get(doc, d, b"Decode")
1782            .and_then(|o| nums(doc, o))
1783            .unwrap_or_default();
1784        let ncomp = if func.is_some() { 1 } else { cs.components() };
1785        if decode.len() < 4 + 2 * ncomp {
1786            return;
1787        }
1788        let mut reader = BitReader::new(data);
1789        let read_val = |r: &mut BitReader, bits: u32, dmin: f64, dmax: f64| -> Option<f64> {
1790            let v = r.read(bits)?;
1791            let max = if bits >= 32 {
1792                u32::MAX as f64
1793            } else {
1794                ((1u64 << bits) - 1) as f64
1795            };
1796            Some(dmin + f64::from(v) * (dmax - dmin) / max)
1797        };
1798        let color_of = |comps: &[f64]| -> [f64; 3] {
1799            let out = match func {
1800                Some(f) => f.eval(&comps[..1]),
1801                None => comps.to_vec(),
1802            };
1803            cs.to_rgb(&out).unwrap_or([0.0, 0.0, 0.0])
1804        };
1805        let mut triangles: Vec<Triangle> = Vec::new();
1806        let mut patches: Vec<Patch> = Vec::new();
1807        let read_vertex = |r: &mut BitReader| -> Option<Vertex> {
1808            let x = read_val(r, bpc, decode[0], decode[1])?;
1809            let y = read_val(r, bpc, decode[2], decode[3])?;
1810            let mut comps = Vec::with_capacity(ncomp);
1811            for c in 0..ncomp {
1812                comps.push(read_val(r, bpcomp, decode[4 + 2 * c], decode[5 + 2 * c])?);
1813            }
1814            Some(((x, y), color_of(&comps)))
1815        };
1816        match stype {
1817            4 => {
1818                let mut prev: Vec<((f64, f64), [f64; 3])> = Vec::new();
1819                let mut count = 0;
1820                while let Some(flag) = reader.read(bpf) {
1821                    let Some(v) = read_vertex(&mut reader) else {
1822                        break;
1823                    };
1824                    reader.align();
1825                    if flag == 0 {
1826                        // Start a new triangle: two more vertices with flags.
1827                        let mut tri = vec![v];
1828                        for _ in 0..2 {
1829                            let _ = reader.read(bpf);
1830                            let Some(v2) = read_vertex(&mut reader) else {
1831                                break;
1832                            };
1833                            reader.align();
1834                            tri.push(v2);
1835                        }
1836                        if tri.len() == 3 {
1837                            prev = tri;
1838                        } else {
1839                            break;
1840                        }
1841                    } else if prev.len() == 3 {
1842                        if flag == 1 {
1843                            prev = vec![prev[1], prev[2], v];
1844                        } else {
1845                            prev = vec![prev[0], prev[2], v];
1846                        }
1847                    } else {
1848                        break;
1849                    }
1850                    let pts = [prev[0].0, prev[1].0, prev[2].0];
1851                    let col = mean3(&[prev[0].1, prev[1].1, prev[2].1]);
1852                    triangles.push((pts, col));
1853                    count += 1;
1854                    if count > 200_000 {
1855                        break;
1856                    }
1857                }
1858            }
1859            5 => {
1860                let per_row = get_int(doc, d, b"VerticesPerRow").unwrap_or(2).max(2) as usize;
1861                let mut rows: Vec<Vec<Vertex>> = Vec::new();
1862                loop {
1863                    let mut row = Vec::with_capacity(per_row);
1864                    for _ in 0..per_row {
1865                        match read_vertex(&mut reader) {
1866                            Some(v) => row.push(v),
1867                            None => break,
1868                        }
1869                    }
1870                    if row.len() < per_row {
1871                        break;
1872                    }
1873                    rows.push(row);
1874                    if rows.len() > 4096 {
1875                        break;
1876                    }
1877                }
1878                for r in 1..rows.len() {
1879                    for c in 1..per_row {
1880                        let (a, b, cc, dd) = (
1881                            rows[r - 1][c - 1],
1882                            rows[r - 1][c],
1883                            rows[r][c - 1],
1884                            rows[r][c],
1885                        );
1886                        triangles.push(([a.0, b.0, cc.0], mean3(&[a.1, b.1, cc.1])));
1887                        triangles.push(([b.0, dd.0, cc.0], mean3(&[b.1, dd.1, cc.1])));
1888                    }
1889                }
1890            }
1891            6 | 7 => {
1892                let npts = if stype == 6 { 12 } else { 16 };
1893                let mut prev_pts: Vec<(f64, f64)> = Vec::new();
1894                let mut prev_cols: Vec<[f64; 3]> = Vec::new();
1895                while let Some(flag) = reader.read(bpf) {
1896                    let (np, nc) = if flag == 0 { (npts, 4) } else { (npts - 4, 2) };
1897                    let mut pts = Vec::with_capacity(16);
1898                    let mut ok = true;
1899                    for _ in 0..np {
1900                        match (
1901                            read_val(&mut reader, bpc, decode[0], decode[1]),
1902                            read_val(&mut reader, bpc, decode[2], decode[3]),
1903                        ) {
1904                            (Some(x), Some(y)) => pts.push((x, y)),
1905                            _ => {
1906                                ok = false;
1907                                break;
1908                            }
1909                        }
1910                    }
1911                    if !ok {
1912                        break;
1913                    }
1914                    let mut cols = Vec::with_capacity(4);
1915                    for _ in 0..nc {
1916                        let mut comps = Vec::with_capacity(ncomp);
1917                        for c in 0..ncomp {
1918                            match read_val(
1919                                &mut reader,
1920                                bpcomp,
1921                                decode[4 + 2 * c],
1922                                decode[5 + 2 * c],
1923                            ) {
1924                                Some(v) => comps.push(v),
1925                                None => {
1926                                    ok = false;
1927                                    break;
1928                                }
1929                            }
1930                        }
1931                        if !ok {
1932                            break;
1933                        }
1934                        cols.push(color_of(&comps));
1935                    }
1936                    if !ok {
1937                        break;
1938                    }
1939                    reader.align();
1940                    // Shared edge from the previous patch (8.7.4.5.7, Table 85).
1941                    let (full_pts, full_cols) = if flag == 0 || prev_pts.len() < 12 {
1942                        if flag != 0 {
1943                            break;
1944                        }
1945                        (pts.clone(), cols.clone())
1946                    } else {
1947                        let p = &prev_pts;
1948                        let edge: [(f64, f64); 4] = match flag {
1949                            1 => [p[3], p[4], p[5], p[6]],
1950                            2 => [p[6], p[7], p[8], p[9]],
1951                            _ => [p[9], p[10], p[11], p[0]],
1952                        };
1953                        let pc = &prev_cols;
1954                        let ec: [[f64; 3]; 2] = match flag {
1955                            1 => [pc[1], pc[2]],
1956                            2 => [pc[2], pc[3]],
1957                            _ => [pc[3], pc[0]],
1958                        };
1959                        let mut fp = edge.to_vec();
1960                        fp.extend(pts.iter().copied());
1961                        let mut fc = ec.to_vec();
1962                        fc.extend(cols.iter().copied());
1963                        (fp, fc)
1964                    };
1965                    if full_pts.len() < 12 || full_cols.len() < 4 {
1966                        break;
1967                    }
1968                    patches.push((full_pts[..12].to_vec(), mean4(&full_cols)));
1969                    prev_pts = full_pts;
1970                    prev_cols = full_cols;
1971                    if patches.len() > 50_000 {
1972                        break;
1973                    }
1974                }
1975            }
1976            _ => {}
1977        }
1978        let ts = matrix.to_ts();
1979        for (pts, col) in &triangles {
1980            let mut pb = PathBuilder::new();
1981            pb.move_to(pts[0].0 as f32, pts[0].1 as f32);
1982            pb.line_to(pts[1].0 as f32, pts[1].1 as f32);
1983            pb.line_to(pts[2].0 as f32, pts[2].1 as f32);
1984            pb.close();
1985            if let Some(p) = pb.finish().and_then(|p| p.transform(ts)) {
1986                let mut paint = Self::paint_for(*col, alpha, st.eff_blend());
1987                paint.anti_alias = false;
1988                self.canvas
1989                    .fill_path(&p, &paint, FillRule::Winding, Transform::identity(), mask);
1990            }
1991        }
1992        for (pts, col) in &patches {
1993            // The boundary: four cubics p0 p1 p2 p3 | p3 p4 p5 p6 | p6 p7 p8 p9 | p9 p10 p11 p0.
1994            let mut pb = PathBuilder::new();
1995            pb.move_to(pts[0].0 as f32, pts[0].1 as f32);
1996            for k in 0..4 {
1997                let (a, b, c) = (
1998                    pts[(3 * k + 1) % 12],
1999                    pts[(3 * k + 2) % 12],
2000                    pts[(3 * k + 3) % 12],
2001                );
2002                pb.cubic_to(
2003                    a.0 as f32, a.1 as f32, b.0 as f32, b.1 as f32, c.0 as f32, c.1 as f32,
2004                );
2005            }
2006            pb.close();
2007            if let Some(p) = pb.finish().and_then(|p| p.transform(ts)) {
2008                let mut paint = Self::paint_for(*col, alpha, st.eff_blend());
2009                paint.anti_alias = false;
2010                self.canvas
2011                    .fill_path(&p, &paint, FillRule::Winding, Transform::identity(), mask);
2012            }
2013        }
2014    }
2015
2016    /// Fill `dev` with a pattern: a shading pattern paints its shading
2017    /// through the path; a tiling pattern renders one cell into a pixmap and
2018    /// repeats it.
2019    fn fill_with_pattern(
2020        &mut self,
2021        dev: &Path,
2022        rule: FillRule,
2023        pat: &Object,
2024        st: &GState,
2025        resources: Option<&Dictionary>,
2026    ) {
2027        let doc = self.doc;
2028        let Some(pd) = as_dict(doc, pat) else { return };
2029        let ptype = get_int(doc, pd, b"PatternType").unwrap_or(2);
2030        let pmatrix = get(doc, pd, b"Matrix")
2031            .and_then(|o| nums(doc, o))
2032            .and_then(|v| Mat::from_slice(&v))
2033            .unwrap_or(Mat::IDENTITY);
2034        // Pattern space is the default space of the page (or form) the
2035        // pattern is a resource of: the base CTM recorded for this stream.
2036        let base = self.pattern_base(st);
2037        let m = pmatrix.then(base);
2038        if ptype == 2 {
2039            let Some(sh) = get(doc, pd, b"Shading") else {
2040                return;
2041            };
2042            let sh = sh.clone();
2043            self.paint_shading(&sh, m, st, Some((dev, rule)), st.eff_fill_alpha(), false);
2044            return;
2045        }
2046        // Tiling pattern.
2047        let Some(stream) = as_stream(doc, pat) else {
2048            return;
2049        };
2050        let Some(bbox) = get(doc, pd, b"BBox")
2051            .and_then(|o| nums(doc, o))
2052            .filter(|b| b.len() == 4)
2053        else {
2054            return;
2055        };
2056        let mut xstep = get_num(doc, pd, b"XStep")
2057            .unwrap_or(bbox[2] - bbox[0])
2058            .abs();
2059        let mut ystep = get_num(doc, pd, b"YStep")
2060            .unwrap_or(bbox[3] - bbox[1])
2061            .abs();
2062        if xstep < 1e-9 {
2063            xstep = (bbox[2] - bbox[0]).abs().max(1e-3);
2064        }
2065        if ystep < 1e-9 {
2066            ystep = (bbox[3] - bbox[1]).abs().max(1e-3);
2067        }
2068        let paint_type = get_int(doc, pd, b"PaintType").unwrap_or(1);
2069        let scale = m.max_scale().max(1e-6);
2070        let cell_w = (xstep * scale).ceil().clamp(1.0, 2048.0);
2071        let cell_h = (ystep * scale).ceil().clamp(1.0, 2048.0);
2072        let s_x = cell_w / xstep;
2073        let s_y = cell_h / ystep;
2074        let (x0, y0) = (bbox[0].min(bbox[2]), bbox[1].min(bbox[3]));
2075        // Pattern space → tile pixmap (y down).
2076        let to_tile = Mat::new(s_x, 0.0, 0.0, -s_y, -x0 * s_x, (y0 + ystep) * s_y);
2077        let Some(mut tile) = Pixmap::new(cell_w as u32, cell_h as u32) else {
2078            return;
2079        };
2080        tile.fill(tiny_skia::Color::TRANSPARENT);
2081        let Ok(content) = stream.decompressed_content() else {
2082            return;
2083        };
2084        let res = get_dict(doc, &stream.dict, b"Resources").or(resources);
2085        // Render the cell with a nested interpreter sharing the font cache.
2086        let mut inner = Interp {
2087            doc,
2088            canvas: tile,
2089            device: Box2::new(0.0, 0.0, cell_w, cell_h),
2090            shared: self.shared.clone(),
2091            images: HashMap::new(),
2092            rect_masks: HashMap::new(),
2093            shape_masks: HashMap::new(),
2094            ops: self.ops,
2095            warned_no_face: self.warned_no_face,
2096            type3_depth: self.type3_depth,
2097            base: to_tile,
2098            bitmap_hint: self.bitmap_hint,
2099        };
2100        let mut gst = inner.initial_state(to_tile);
2101        if paint_type == 2 {
2102            gst.fixed_color = true;
2103            gst.fill_rgb = st.fill_rgb;
2104            gst.stroke_rgb = st.fill_rgb;
2105        }
2106        inner.run(&content, res, gst, MAX_DEPTH - 2);
2107        self.ops = inner.ops;
2108        let tile = inner.canvas;
2109        // tile pixmap → pattern space → device.
2110        let Some(from_tile) = to_tile.invert() else {
2111            return;
2112        };
2113        let shader_m = from_tile.then(m);
2114        let shader = tiny_skia::Pattern::new(
2115            tile.as_ref(),
2116            SpreadMode::Repeat,
2117            FilterQuality::Bilinear,
2118            st.eff_fill_alpha() as f32,
2119            shader_m.to_ts(),
2120        );
2121        let paint = Paint {
2122            shader,
2123            blend_mode: st.eff_blend(),
2124            anti_alias: true,
2125            force_hq_pipeline: false,
2126            colorspace: tiny_skia::ColorSpace::Linear,
2127        };
2128        let mask = self.clip_mask_for(st, Some(dev.bounds()));
2129        self.canvas
2130            .fill_path(dev, &paint, rule, Transform::identity(), mask.as_deref());
2131    }
2132
2133    /// The base transform for pattern space: the page's (or the enclosing
2134    /// form's) default user space. Recorded on the interpreter per stream;
2135    /// here the page base is used for everything, which is exact for page
2136    /// content and the common case for forms.
2137    fn pattern_base(&self, _st: &GState) -> Mat {
2138        self.base
2139    }
2140}
2141
2142/// The coverage mask of a shape clip: `dev` (already in device space)
2143/// rasterized into a fresh mask, cut to `keep` (the new clip box) and
2144/// multiplied by the `parent` mask when the clip nests. Built by hand
2145/// rather than `Mask::intersect_path`: that rasterizes into a canvas-sized
2146/// temporary and multiplies the *whole* canvas, after a full-canvas
2147/// rectangle fill seeded the box clip — three or four passes over 4 MB
2148/// per clip, and a designer's page sets a few hundred clips. Here the only
2149/// full-canvas cost is the allocation; every other pass covers the shape's
2150/// bounding box.
2151fn shape_mask(
2152    w: u32,
2153    h: u32,
2154    dev: &Path,
2155    rule: FillRule,
2156    keep: Box2,
2157    parent: Option<&Mask>,
2158) -> Option<Mask> {
2159    let mut m = Mask::new(w, h)?;
2160    m.fill_path(dev, rule, true, Transform::identity());
2161    let bb = dev.bounds();
2162    let (w, h) = (w as usize, h as usize);
2163    // The rows and columns the shape may have touched.
2164    let bx0 = (bb.left().floor().max(0.0) as usize).min(w);
2165    let by0 = (bb.top().floor().max(0.0) as usize).min(h);
2166    let bx1 = ((bb.right().ceil().max(0.0) as usize) + 1).min(w);
2167    let by1 = ((bb.bottom().ceil().max(0.0) as usize) + 1).min(h);
2168    // The pixels that stay: the shape's box ∩ the clip box. A pixel a box
2169    // edge cuts through keeps the covered fraction (what the anti-aliased
2170    // rectangle mask this replaces gave it).
2171    let kx0 = (keep.x0.floor().max(0.0) as usize).clamp(bx0, bx1);
2172    let ky0 = (keep.y0.floor().max(0.0) as usize).clamp(by0, by1);
2173    let kx1 = (keep.x1.ceil().max(0.0) as usize).clamp(kx0, bx1);
2174    let ky1 = (keep.y1.ceil().max(0.0) as usize).clamp(ky0, by1);
2175    // Coverage of an edge pixel along one axis: the part of [i, i + 1)
2176    // inside [lo, hi).
2177    let edge = |i: usize, lo: f64, hi: f64| -> f64 {
2178        let (a, b) = (i as f64, i as f64 + 1.0);
2179        (b.min(hi) - a.max(lo)).clamp(0.0, 1.0)
2180    };
2181    let fx: Vec<f64> = (kx0..kx1).map(|x| edge(x, keep.x0, keep.x1)).collect();
2182    let plain_x = fx.iter().all(|&f| f >= 1.0);
2183    let data = m.data_mut();
2184    for y in by0..by1 {
2185        let row = &mut data[y * w..(y + 1) * w];
2186        if y < ky0 || y >= ky1 {
2187            row[bx0..bx1].fill(0);
2188            continue;
2189        }
2190        row[bx0..kx0].fill(0);
2191        row[kx1..bx1].fill(0);
2192        let fy = edge(y, keep.y0, keep.y1);
2193        let prow = parent.map(|p| &p.data()[y * w..(y + 1) * w]);
2194        if prow.is_none() && plain_x && fy >= 1.0 {
2195            continue;
2196        }
2197        for x in kx0..kx1 {
2198            let mut c = f64::from(row[x]) * fy * fx[x - kx0];
2199            if let Some(prow) = prow {
2200                c = c * f64::from(prow[x]) / 255.0;
2201            }
2202            row[x] = c.round().clamp(0.0, 255.0) as u8;
2203        }
2204    }
2205    Some(m)
2206}
2207
2208/// A hash of a device path's geometry (and the box it is clipped into).
2209fn path_hash(p: &Path, clip_box: Box2) -> u64 {
2210    use std::hash::{Hash, Hasher};
2211    let mut h = std::collections::hash_map::DefaultHasher::new();
2212    for pt in p.points() {
2213        pt.x.to_bits().hash(&mut h);
2214        pt.y.to_bits().hash(&mut h);
2215    }
2216    for v in p.verbs() {
2217        (*v as u8).hash(&mut h);
2218    }
2219    for v in [clip_box.x0, clip_box.y0, clip_box.x1, clip_box.y1] {
2220        v.to_bits().hash(&mut h);
2221    }
2222    h.finish()
2223}
2224
2225/// One closed sub-path of lines whose every vertex lies on a corner of its
2226/// bounding box — an axis-aligned rectangle (`get_axis_aligned_clip_rect`).
2227fn device_path_is_rect(p: &Path) -> bool {
2228    let b = p.bounds();
2229    if b.width() <= 1e-3 || b.height() <= 1e-3 {
2230        return false;
2231    }
2232    let mut moves = 0;
2233    let mut points = 0;
2234    for seg in p.segments() {
2235        match seg {
2236            tiny_skia::PathSegment::MoveTo(pt) => {
2237                moves += 1;
2238                if moves > 1 || !on_corner(pt, &b) {
2239                    return false;
2240                }
2241                points += 1;
2242            }
2243            tiny_skia::PathSegment::LineTo(pt) => {
2244                if !on_corner(pt, &b) {
2245                    return false;
2246                }
2247                points += 1;
2248            }
2249            tiny_skia::PathSegment::Close => {}
2250            _ => return false,
2251        }
2252    }
2253    (4..=5).contains(&points)
2254}
2255
2256fn on_corner(pt: Point, b: &tiny_skia::Rect) -> bool {
2257    let eps = 1e-3;
2258    let on_x = (pt.x - b.left()).abs() <= eps || (pt.x - b.right()).abs() <= eps;
2259    let on_y = (pt.y - b.top()).abs() <= eps || (pt.y - b.bottom()).abs() <= eps;
2260    on_x && on_y
2261}
2262
2263/// The Type 3 glyph name for a code: through the encoding's `/Differences`.
2264fn font_type3_name(font: &LoadedFont, code: u32) -> Option<String> {
2265    font.type3_glyph_name(code)
2266}
2267
2268fn mean3(c: &[[f64; 3]; 3]) -> [f64; 3] {
2269    [
2270        (c[0][0] + c[1][0] + c[2][0]) / 3.0,
2271        (c[0][1] + c[1][1] + c[2][1]) / 3.0,
2272        (c[0][2] + c[1][2] + c[2][2]) / 3.0,
2273    ]
2274}
2275
2276fn mean4(c: &[[f64; 3]]) -> [f64; 3] {
2277    let n = c.len().max(1) as f64;
2278    let mut out = [0.0; 3];
2279    for col in c {
2280        for k in 0..3 {
2281            out[k] += col[k] / n;
2282        }
2283    }
2284    out
2285}
2286
2287/// Sample a shading function into gradient stops over `[t0, t1]`; a
2288/// non-extended end gets a transparent guard stop just past it, as
2289/// docling-parse does (Blend2D's pad mode is the only one that keeps the
2290/// interior ramp intact).
2291fn sample_stops(
2292    f: &Function,
2293    cs: &ColorSpace,
2294    domain: &[f64],
2295    alpha: f64,
2296    e0: bool,
2297    e1: bool,
2298) -> Vec<GradientStop> {
2299    let (t0, t1) = (
2300        domain.first().copied().unwrap_or(0.0),
2301        domain.get(1).copied().unwrap_or(1.0),
2302    );
2303    const N: usize = 64;
2304    let mut stops = Vec::with_capacity(N + 3);
2305    let a = to_u8(alpha);
2306    let guard = 1.0 / 1024.0;
2307    for i in 0..=N {
2308        let u = i as f64 / N as f64;
2309        let t = t0 + (t1 - t0) * u;
2310        let out = f.eval(&[t]);
2311        let rgb = cs.to_rgb(&out).unwrap_or([0.0, 0.0, 0.0]);
2312        let color = tiny_skia::Color::from_rgba8(to_u8(rgb[0]), to_u8(rgb[1]), to_u8(rgb[2]), a);
2313        let pos = if !e0 && i == 0 {
2314            guard
2315        } else if !e1 && i == N {
2316            1.0 - guard
2317        } else {
2318            u
2319        };
2320        stops.push(GradientStop::new(pos as f32, color));
2321    }
2322    if !e0 {
2323        stops.insert(0, GradientStop::new(0.0, tiny_skia::Color::TRANSPARENT));
2324    }
2325    if !e1 {
2326        stops.push(GradientStop::new(1.0, tiny_skia::Color::TRANSPARENT));
2327    }
2328    stops
2329}
2330
2331struct BitReader<'a> {
2332    data: &'a [u8],
2333    pos: usize,
2334}
2335
2336impl<'a> BitReader<'a> {
2337    fn new(data: &'a [u8]) -> BitReader<'a> {
2338        BitReader { data, pos: 0 }
2339    }
2340
2341    fn read(&mut self, bits: u32) -> Option<u32> {
2342        if self.pos + bits as usize > self.data.len() * 8 {
2343            return None;
2344        }
2345        let mut v: u64 = 0;
2346        for _ in 0..bits {
2347            let byte = self.data[self.pos / 8];
2348            let bit = (byte >> (7 - self.pos % 8)) & 1;
2349            v = (v << 1) | u64::from(bit);
2350            self.pos += 1;
2351        }
2352        Some(v as u32)
2353    }
2354
2355    fn align(&mut self) {
2356        self.pos = self.pos.div_ceil(8) * 8;
2357    }
2358}