Skip to main content

pdfrum_render/
walk.rs

1//! The master walk: one page-object list, dispatched by kind.
2//!
3//! Two behaviours of the walk itself are load-bearing. The **cull test** is
4//! computed once per list from the inverse-transformed device clip box and
5//! uses **strict** inequalities, so an object exactly touching the clip edge
6//! is kept. And a **shading that fails is never retried**: every other kind
7//! falls back to a re-render that degenerates to an identical call on a
8//! bitmap device, so a failure is a skipped object and a diagnostic.
9
10// A dispatch `match` plus one free function per kind, each taking the small
11// `RenderCtx` record and the backend's own device, rather than the oracle's
12// thousand-line `CPDF_RenderStatus` with twenty-four members and a `Process*`
13// method per kind. The walk is generic over the backend rather than dynamic,
14// because `RasterBackend::snapshot` needs the concrete device to read pixels
15// back; `dyn RenderDevice` survives only where a device is genuinely
16// swappable — the Coons scratch buffer.
17
18use std::sync::Arc;
19
20use kurbo::{Affine, Rect, Shape};
21use pdfrum_common::{Deadline, Diagnostics, Operation};
22use pdfrum_page::{Page, PageObject, Visibility};
23
24use crate::clip;
25use crate::color::{Argb, ObjectKind, resolve_argb};
26use crate::ctx::{
27    GroupNesting, Inherited, Nesting, RenderCaches, RenderCtx, Type3Ancestry, Type3Colour,
28    Type3Frame,
29};
30use crate::device::{ImageQuality, MAX_TARGET_DIMENSION, RasterBackend, RenderDevice};
31use crate::error::Error;
32use crate::group::{GroupFinish, GroupInputs, needs_backdrop, needs_offscreen};
33use crate::image::{
34    apply_stretched_mask, color_as_fill_mask, effective_quality, overprint_blend, resample_quality,
35    resample_quality_at, takes_other_transform, to_pixmap,
36};
37use crate::options::RenderOptions;
38use crate::paint::{PathPaint, draw_path};
39use crate::path::{IntRect, is_available_matrix, outer_rect};
40use crate::pattern::PatternClip;
41use crate::pixmap::{Pixmap, alpha_byte_rounding, alpha_byte_truncating};
42use crate::region::Region;
43use crate::shading;
44use crate::text::{has_face, paint_kinds, stroke_text_matrices};
45use crate::transfer::TransferFunc;
46use crate::window::WindowBackend;
47
48/// What one render call may reuse or restrict, beyond the page and the
49/// options.
50///
51/// A plain record with [`Default`], so a caller names only the parts it has:
52/// `RenderSession { caches: Some(&mut caches), ..Default::default() }`. Both
53/// fields are absent by default and both defaults are the conservative
54/// answer — fresh caches, nothing hidden — which is why [`render_page`] can
55/// be the whole API for a caller who wants neither.
56///
57/// It carries borrows rather than owning anything: the caches outlive the
58/// call by construction (that is what they are for), and the visibility tree
59/// belongs to the pre-pass that computed it.
60#[derive(Debug, Default)]
61pub struct RenderSession<'a> {
62    /// Caches to reuse across calls, instead of a fresh set per page: a
63    /// caller rendering many pages of one document flattens each glyph
64    /// outline once for the run rather than once per page.
65    ///
66    /// # Determinism
67    ///
68    /// Type-3 blue-zone snapping is order-dependent by design (see
69    /// [`RenderCaches`]), so a page rendered with a *warm* cache can differ
70    /// by a snapped pixel from one rendered cold. Reuse across pages of one
71    /// document is intended; reusing caches across *unrelated* documents
72    /// makes output depend on what was rendered before. For a byte-identical
73    /// baseline, leave this `None`.
74    pub caches: Option<&'a mut RenderCaches>,
75    /// Which objects optional content leaves visible. `None` draws them all.
76    ///
77    /// It comes from [`pdfrum_page::page_visibility`], a **pre-pass** over
78    /// the same `page` with the document's `/OCProperties`. Splitting it out
79    /// is what keeps the render API free of a resolver: deciding visibility
80    /// needs indirect-object lookup and a mutable evaluation cache, and
81    /// consuming the answer needs neither — it is one index per object.
82    pub visible: Option<&'a Visibility>,
83    /// When the render must have stopped. `None` — the default — is no
84    /// limit.
85    ///
86    /// Read once before the target is allocated and once per object during
87    /// the walk (nested lists included), so a page that has run out of time
88    /// stops at the next object; the render then fails with
89    /// [`Error::Limit`] rather than returning a pixmap with the rest of the
90    /// page missing. Costs one branch per object when unset. A borrow, like
91    /// the other two: the caller's `Limits` owns it.
92    pub deadline: Option<&'a Deadline>,
93    /// A stop for **this render alone**. `None` — the default — is none.
94    ///
95    /// Read exactly as [`deadline`](Self::deadline) is, and either passing
96    /// ends the render with [`Error::Limit`]. The difference is who owns it:
97    /// the document's deadline outlives every render and, once passed, stays
98    /// passed, while a `cancel` is made for one call and dropped with it, so
99    /// a viewer abandoning a stale tile raises it and the next render starts
100    /// clean.
101    pub cancel: Option<&'a Deadline>,
102    /// How much of the page to draw. [`Region::Whole`] by default.
103    ///
104    /// With [`Region::Rect`] the pixmap is exactly the rectangle's size and
105    /// each of its pixels is the pixel a whole-page render has there; the
106    /// whole page is never allocated. See [`Region`].
107    pub region: Region,
108}
109
110/// Render a page into a pixmap.
111///
112/// The target size comes from the page's display box under
113/// `opts.transform`; the background follows the oracle — opaque white for a
114/// page without transparency, fully transparent for one with it — unless
115/// overridden, and that choice is load-bearing rather than cosmetic.
116///
117/// Every page gets its own caches and every object is drawn. A caller that
118/// wants either of those different — a run over many pages, or a document
119/// with optional content — calls [`render_page_with`] instead, which is this
120/// function with a [`RenderSession`] the caller fills in.
121///
122/// # Errors
123///
124/// [`Error::TargetEmpty`] when the page's box under `opts.transform` is not
125/// at least one pixel on both axes (a zero, negative or non-finite size), and
126/// [`Error::TargetTooLarge`] when either axis exceeds
127/// [`MAX_TARGET_DIMENSION`]. Damage inside the page is reported through
128/// `diags` and never becomes an error.
129pub fn render_page<B: RasterBackend>(
130    page: &Page,
131    opts: &RenderOptions,
132    backend: &B,
133    diags: &mut Diagnostics,
134) -> Result<Pixmap, Error> {
135    render_page_with(page, opts, backend, RenderSession::default(), diags)
136}
137
138/// Render a page, reusing caller-owned caches and honouring optional content.
139///
140/// The general entry point: [`render_page`] is this with a default
141/// [`RenderSession`], and is the right call when neither of the session's two
142/// parts applies.
143///
144/// # Errors
145///
146/// As [`render_page`].
147pub fn render_page_with<B: RasterBackend>(
148    page: &Page,
149    opts: &RenderOptions,
150    backend: &B,
151    session: RenderSession<'_>,
152    diags: &mut Diagnostics,
153) -> Result<Pixmap, Error> {
154    let device = render_page_to_device_with(page, opts, backend, session, diags)?;
155    Ok(backend.finish(device))
156}
157
158/// Record a page onto a backend device without rasterizing the root target.
159///
160/// Same walk as [`render_page`], but the caller owns the device afterwards:
161/// a GPU embedder dispatches it into a texture with
162/// `VelloBackend::render_to_view` and never reads pixels; a CPU caller
163/// still calls [`RasterBackend::finish`]. Offscreen `finish` calls inside
164/// the walk — groups, masks, pattern cells — are unchanged.
165///
166/// ```
167/// use pdfrum_common::Diagnostics;
168/// use pdfrum_page::Page;
169/// use pdfrum_raster_tinyskia::TinySkiaBackend;
170/// use pdfrum_render::{RasterBackend, RenderOptions, render_page_to_device};
171///
172/// let backend = TinySkiaBackend;
173/// let device = render_page_to_device(
174///     &Page::empty(),
175///     &RenderOptions::default(),
176///     &backend,
177///     &mut Diagnostics::default(),
178/// )?;
179/// let pixmap = backend.finish(device);
180/// assert_eq!((pixmap.width(), pixmap.height()), (612, 792));
181/// # Ok::<(), pdfrum_render::Error>(())
182/// ```
183///
184/// # Errors
185///
186/// As [`render_page`].
187pub fn render_page_to_device<B: RasterBackend>(
188    page: &Page,
189    opts: &RenderOptions,
190    backend: &B,
191    diags: &mut Diagnostics,
192) -> Result<B::Device, Error> {
193    render_page_to_device_with(page, opts, backend, RenderSession::default(), diags)
194}
195
196/// [`render_page_to_device`] with a [`RenderSession`].
197///
198/// ```
199/// use pdfrum_common::Diagnostics;
200/// use pdfrum_page::Page;
201/// use pdfrum_raster_tinyskia::TinySkiaBackend;
202/// use pdfrum_render::{
203///     RasterBackend, RenderOptions, RenderSession, render_page_to_device_with,
204/// };
205///
206/// let backend = TinySkiaBackend;
207/// let device = render_page_to_device_with(
208///     &Page::empty(),
209///     &RenderOptions::default(),
210///     &backend,
211///     RenderSession::default(),
212///     &mut Diagnostics::default(),
213/// )?;
214/// let _ = backend.finish(device);
215/// # Ok::<(), pdfrum_render::Error>(())
216/// ```
217///
218/// # Errors
219///
220/// As [`render_page`].
221pub fn render_page_to_device_with<B: RasterBackend>(
222    page: &Page,
223    opts: &RenderOptions,
224    backend: &B,
225    session: RenderSession<'_>,
226    diags: &mut Diagnostics,
227) -> Result<B::Device, Error> {
228    let RenderSession {
229        caches,
230        visible,
231        deadline,
232        cancel,
233        region,
234    } = session;
235    // The two `None` arms need somewhere to live that outlasts the call, so
236    // each default is bound here and borrowed rather than built inline.
237    let mut fresh_caches = RenderCaches::new();
238    let all_visible = Visibility::all_visible();
239    let visible = visible.unwrap_or(&all_visible);
240    let caches = caches.unwrap_or(&mut fresh_caches);
241    render_page_inner(
242        page,
243        opts,
244        backend,
245        visible,
246        caches,
247        Stops { deadline, cancel },
248        region,
249        diags,
250    )
251}
252
253/// Device pixels added around a tile before the cull test: the room an
254/// antialiased or resampled edge, and the one-pixel minimum stroke, need.
255const TILE_CULL_MARGIN: f64 = 2.0;
256
257/// The two stops a render honours: the caller's document-wide deadline and
258/// the one made for this render.
259#[derive(Clone, Copy)]
260struct Stops<'a> {
261    deadline: Option<&'a Deadline>,
262    cancel: Option<&'a Deadline>,
263}
264
265impl Stops<'_> {
266    /// `Ok` unless either stop is set and has passed.
267    fn check(self) -> Result<(), Error> {
268        for stop in [self.deadline, self.cancel].into_iter().flatten() {
269            stop.check(Operation::Render).map_err(Error::Limit)?;
270        }
271        Ok(())
272    }
273}
274
275/// The body both entry points share.
276#[expect(
277    clippy::too_many_arguments,
278    reason = "the page, its options, the backend, what the session lends, and \
279              where to write diagnostics"
280)]
281fn render_page_inner<B: RasterBackend>(
282    page: &Page,
283    opts: &RenderOptions,
284    backend: &B,
285    visible: &Visibility,
286    caches: &mut RenderCaches,
287    stops: Stops<'_>,
288    region: Region,
289    diags: &mut Diagnostics,
290) -> Result<B::Device, Error> {
291    // A region is a window onto the *same* page matrix a whole render uses,
292    // moved so the window's corner is the pixmap's origin. The matrix is
293    // never recomputed for the window, which is what keeps a tile's pixel
294    // equal to the whole render's: see `Region`.
295    let (w, h, origin) = match region {
296        Region::Whole => {
297            let (w, h) = target_size(page, opts)?;
298            (w, h, (0, 0))
299        }
300        Region::Rect(rect) => {
301            let (page_w, page_h) = device_size(page, opts)?;
302            let inside = rect
303                .x()
304                .checked_add(rect.width())
305                .is_some_and(|r| r <= page_w)
306                && rect
307                    .y()
308                    .checked_add(rect.height())
309                    .is_some_and(|b| b <= page_h);
310            if !inside {
311                return Err(Error::RegionOutOfBounds {
312                    x: rect.x(),
313                    y: rect.y(),
314                    width: rect.width(),
315                    height: rect.height(),
316                    page_width: page_w,
317                    page_height: page_h,
318                });
319            }
320            (rect.width(), rect.height(), (rect.x(), rect.y()))
321        }
322    };
323    // Before the allocation, and again after the walk: a walk that stopped
324    // on the deadline has left the device half-drawn, and the second read is
325    // what turns that into an error rather than a returned pixmap.
326    stops.check()?;
327    let clear = opts.background_for(needs_alpha_background(page));
328    let run = crate::ctx::RunCtx {
329        stop: crate::ctx::Stop {
330            deadline: stops.deadline,
331            cancel: stops.cancel,
332        },
333        cull_margin: match region {
334            Region::Whole => 0.0,
335            Region::Rect(_) => TILE_CULL_MARGIN,
336        },
337    };
338    let ctx = RenderCtx::new(&run, opts.clone(), page.transparency);
339    let to_device = page_matrix(page, opts);
340    let device = match region {
341        Region::Whole => {
342            let mut device = backend.new_target(w, h, clear);
343            let device_box = Rect::new(0.0, 0.0, f64::from(w), f64::from(h));
344            render_object_list(
345                &ctx,
346                &mut device,
347                backend,
348                caches,
349                &page.objects,
350                visible,
351                to_device,
352                device_box,
353                diags,
354            );
355            device
356        }
357        Region::Rect(_) => {
358            // The walk is the whole page's: the same matrix, and a clip box
359            // that is the window's place in the whole frame rather than a
360            // box at the origin. Only the target is the window's size, and
361            // the wrapper moves each call onto it.
362            let window = WindowBackend(backend);
363            let mut device = window.new_window(w, h, clear, origin);
364            let (x, y) = (f64::from(origin.0), f64::from(origin.1));
365            let device_box = Rect::new(x, y, x + f64::from(w), y + f64::from(h));
366            render_object_list(
367                &ctx,
368                &mut device,
369                &window,
370                caches,
371                &page.objects,
372                visible,
373                to_device,
374                device_box,
375                diags,
376            );
377            device.inner
378        }
379    };
380    stops.check()?;
381    Ok(device)
382}
383
384/// Whether the page renders onto a transparent background rather than white.
385///
386/// This is **not** whether the page declares a `/Group`. It is whether any
387/// `/ExtGState` on the page, or in a form it draws, names a blend mode above
388/// `Multiply` — the ones that read the backdrop. A page carrying a plain
389/// `/Group` still renders onto opaque white.
390#[must_use]
391pub fn needs_alpha_background(page: &Page) -> bool {
392    // Reading this as the `/Group` flag turns every such page's output from
393    // RGB to RGBA and, where nothing paints, from white to black — a
394    // whole-page difference, not a pixel one. The flag the oracle actually
395    // reports through `FPDFPage_HasTransparency` is
396    // `CPDF_PageObjectHolder::BackgroundAlphaNeeded`, set by the content
397    // parser in exactly one place (`cpdf_allstates.cpp:105-106`) and
398    // propagated up from a form to its holder
399    // (`cpdf_streamcontentparser.cpp:835-838`).
400    fn any_deep_blend(objects: &[PageObject]) -> bool {
401        objects.iter().any(|object| {
402            let deep = matches!(
403                object.state().general.blend,
404                pdfrum_page::BlendMode::Screen
405                    | pdfrum_page::BlendMode::Overlay
406                    | pdfrum_page::BlendMode::Darken
407                    | pdfrum_page::BlendMode::Lighten
408                    | pdfrum_page::BlendMode::ColorDodge
409                    | pdfrum_page::BlendMode::ColorBurn
410                    | pdfrum_page::BlendMode::HardLight
411                    | pdfrum_page::BlendMode::SoftLight
412                    | pdfrum_page::BlendMode::Difference
413                    | pdfrum_page::BlendMode::Exclusion
414                    | pdfrum_page::BlendMode::Hue
415                    | pdfrum_page::BlendMode::Saturation
416                    | pdfrum_page::BlendMode::Color
417                    | pdfrum_page::BlendMode::Luminosity
418            );
419            // A form's own objects carry the flag up to their holder.
420            deep || match object {
421                PageObject::Form(f) => any_deep_blend(&f.object.objects),
422                _ => false,
423            }
424        })
425    }
426    any_deep_blend(&page.objects)
427}
428
429/// The device size a page renders at, and the errors that size can be.
430///
431/// Public so a caller can size a buffer, lay out a sheet or reject a page
432/// *before* paying for a render — [`render_page`] answers the same question
433/// only by doing the work.
434///
435/// The dimensions **truncate**, they do not round up: an A4 page's 595.276
436/// points become 595 device pixels, not 596. Rounding up instead costs a
437/// one-pixel border on every page whose size is not a whole number — a size
438/// mismatch rather than a pixel difference.
439///
440/// # Errors
441///
442/// [`Error::TargetEmpty`] when the page's box under `opts.transform` is not
443/// at least one pixel on both axes, and [`Error::TargetTooLarge`] when either
444/// axis exceeds [`MAX_TARGET_DIMENSION`].
445pub fn target_size(page: &Page, opts: &RenderOptions) -> Result<(u32, u32), Error> {
446    let (w, h) = device_size(page, opts)?;
447    if w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
448        return Err(Error::TargetTooLarge {
449            width: w,
450            height: h,
451            limit: MAX_TARGET_DIMENSION,
452        });
453    }
454    Ok((w, h))
455}
456
457/// The page's device box under `opts.transform`, truncated as
458/// [`target_size`] truncates it, **without** the backend's size ceiling.
459///
460/// The full page is only ever a coordinate frame for a region render, so its
461/// size may exceed what a pixmap can be.
462#[expect(
463    clippy::cast_possible_truncation,
464    clippy::cast_sign_loss,
465    reason = "the `is_finite` and `>= 1.0` guards run before the successful \
466              cast; in the error arm `max(0.0)` floors the value and Rust's \
467              saturating float-to-int cast turns a huge or NaN size into a \
468              reported number rather than wrapping"
469)]
470fn device_size(page: &Page, opts: &RenderOptions) -> Result<(u32, u32), Error> {
471    let (pw, ph) = page.display_size();
472    let corners = opts
473        .transform
474        .transform_rect_bbox(Rect::new(0.0, 0.0, pw, ph));
475    let w = corners.width().trunc();
476    let h = corners.height().trunc();
477    if !w.is_finite() || !h.is_finite() || w < 1.0 || h < 1.0 {
478        return Err(Error::TargetEmpty {
479            width: w.max(0.0) as u32,
480            height: h.max(0.0) as u32,
481        });
482    }
483    Ok((w as u32, h as u32))
484}
485
486/// Page space to device space.
487///
488/// Three transforms compose, and the middle one is the easy omission: the
489/// page's display matrix normalises the crop box's origin and applies the
490/// `/Rotate`, but PDF user space is **y-up** and every device is y-down, so
491/// the y axis must be flipped about the page's height before the caller's
492/// own transform applies. Without it a page renders upside down, which the
493/// symmetric fixtures hide and the asymmetric ones do not.
494///
495/// The flip is about the **device** box, not the page box: the display
496/// matrix divides the *truncated integer* bitmap size by the page's float
497/// size, so an A4 page 841.89 points tall renders into 841 device rows with a
498/// y scale of `841 / 841.89`, not 1.
499#[must_use]
500pub fn page_matrix(page: &Page, opts: &RenderOptions) -> Affine {
501    // Flipping about the float height instead leaves a shear of up to a
502    // device pixel between the top of the page and the bottom. That is
503    // invisible while every glyph is filled at its true position — it moves a
504    // stem edge by a fraction of a count — and stops being invisible the
505    // moment glyph origins are snapped, since a y that was 0.49 off is then a
506    // whole row off.
507    let (page_w, page_h) = page.display_size();
508    // The device box the oracle fits the page into: the same truncation
509    // `target_size` performs, since that is the bitmap that gets allocated.
510    // A page that cannot be sized at all keeps the float box, which is what
511    // the render is about to reject anyway.
512    // `device_size`, not `target_size`: a region render of a page too big to
513    // be one pixmap must use the same fit the whole render would.
514    let device =
515        device_size(page, opts).map_or((page_w, page_h), |(w, h)| (f64::from(w), f64::from(h)));
516    let (dev_w, dev_h) = device;
517    if page_w <= 0.0 || page_h <= 0.0 || !dev_w.is_finite() || !dev_h.is_finite() {
518        return opts.transform * page.rotate.display_matrix(page.crop_box);
519    }
520    // `opts.transform` has already been consumed in choosing the device box,
521    // exactly as `pdfium_test` consumes its `--scale` in sizing the bitmap
522    // and then asks for the display matrix onto that size.
523    let fit = Affine::new([dev_w / page_w, 0.0, 0.0, -dev_h / page_h, 0.0, dev_h]);
524    fit * page.rotate.display_matrix(page.crop_box)
525}
526
527/// Walk one object list.
528///
529/// `device_box` is the device's own extent and is what the cull test works
530/// against, transformed back into object space once for the whole list —
531/// which is why an object's own matrix cannot change it.
532///
533/// `visible` describes *this* list, one entry per object in order. It is the
534/// pre-pass's answer and the walk only reads it — see
535/// [`render_page_with`]. A tree that hides nothing is the common
536/// case and costs one `is_none_or` per object.
537#[expect(
538    clippy::too_many_arguments,
539    reason = "the walk threads context, device, backend, caches and the \
540              visibility describing this list"
541)]
542pub fn render_object_list<B: RasterBackend>(
543    ctx: &RenderCtx<'_>,
544    device: &mut B::Device,
545    backend: &B,
546    caches: &mut RenderCaches,
547    objects: &[PageObject],
548    visible: &Visibility,
549    to_device: Affine,
550    device_box: Rect,
551    diags: &mut Diagnostics,
552) {
553    if !ctx.depth.may_recurse() {
554        return;
555    }
556    // A whole render's box is the page, past which nothing is visible, so the
557    // cull may use each object's bare geometry. A tile's box is the middle of
558    // the page, and what an object paints beyond its geometry — a stroke's
559    // width, an antialiased or resampled edge — can reach into it.
560    let margin = ctx.run.cull_margin;
561    let cull = cull_rect(to_device, device_box.inflate(margin, margin));
562    for (index, object) in objects.iter().enumerate() {
563        // The deadline is read per object — a draw call — and a list that is
564        // out of time returns; the enclosing list reads it again at its next
565        // object, so the whole walk unwinds without a flag.
566        if ctx.run.stop.passed() {
567            return;
568        }
569        // The visibility gate runs first, exactly where `RenderSingleObject`
570        // puts it (`cpdf_renderstatus.cpp:247`): before the clip is pushed,
571        // so a hidden object's clip never reaches the device either.
572        if !visible.visible(index) {
573            continue;
574        }
575        if let Some(cull) = cull
576            && crate::walkprofile::phase(crate::walkprofile::Phase::Cull, || {
577                if margin > 0.0 {
578                    culled(
579                        object,
580                        cull.inflate(stroke_reach(object), stroke_reach(object)),
581                    )
582                } else {
583                    culled(object, cull)
584                }
585            })
586        {
587            continue;
588        }
589        render_object(
590            ctx,
591            device,
592            backend,
593            caches,
594            object,
595            &visible.children(index),
596            to_device,
597            device_box,
598            diags,
599        );
600    }
601}
602
603/// The object-space rectangle the device clip box maps back to, computed once
604/// per list.
605fn cull_rect(to_device: Affine, device_box: Rect) -> Option<Rect> {
606    let det = to_device.determinant();
607    (det != 0.0 && det.is_finite())
608        .then(|| to_device.inverse().transform_rect_bbox(device_box))
609        .filter(|r| r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite())
610}
611
612/// How far, in the units the cull test works in, what `object` paints can
613/// reach past its geometry's bounding box: half a stroke's width times the
614/// longest miter or square cap, under the object's matrix. Zero for a path
615/// that is not stroked and for every other kind.
616///
617/// An over-estimate — the matrix's Frobenius norm bounds its largest scale —
618/// because a cull that keeps too much costs a clip, and one that keeps too
619/// little drops pixels.
620fn stroke_reach(object: &PageObject) -> f64 {
621    let PageObject::Path(p) = object else {
622        return 0.0;
623    };
624    if !p.object.stroke {
625        return 0.0;
626    }
627    let scale = p
628        .object
629        .matrix
630        .as_coeffs()
631        .iter()
632        .take(4)
633        .map(|term| term * term)
634        .sum::<f64>()
635        .sqrt();
636    let params = &p.state.stroke_params;
637    let longest = f64::from(params.miter_limit).max(std::f64::consts::SQRT_2);
638    0.5 * f64::from(params.width).abs() * scale * longest
639}
640
641/// The cull test, with the **strict** inequalities `RenderObjectList` uses.
642///
643/// The progressive renderer spells the complement with `<=`/`>=`, so an
644/// object exactly touching the clip edge is kept there and dropped here.
645/// Only this spelling survives; recorded so nobody "fixes" it later.
646fn culled(object: &PageObject, cull: Rect) -> bool {
647    // A path's exact box is the expensive one and the cull almost never needs
648    // it, so the two cheap boxes that bracket it are tried first. See
649    // `path_cull_bounds`.
650    if let PageObject::Path(p) = object {
651        if let Some((inner, outer)) = path_cull_bounds(&p.object.path, p.object.matrix) {
652            // The endpoint box is a *subset* of the exact box, so an endpoint
653            // box that reaches the clip proves the exact box does — keep, no
654            // solve.
655            if !outside(inner, cull) {
656                return false;
657            }
658            // The control-point hull is a *superset*, so a hull entirely off
659            // the clip proves the exact box is too — cull, no solve.
660            if outside(outer, cull) {
661                return true;
662            }
663        }
664        // Either the bracket declined the path, or the answer lies in the band
665        // between its two boxes. Both need the cubic extrema.
666        return outside(path_bbox(&p.object.path, p.object.matrix), cull);
667    }
668    let Some(bbox) = object_bbox(object) else {
669        return false;
670    };
671    outside(bbox, cull)
672}
673
674/// The strict inequalities `RenderObjectList` culls with.
675///
676/// The progressive renderer spells the complement with `<=`/`>=`, so an object
677/// exactly touching the clip edge is kept there and dropped here.
678fn outside(bbox: Rect, cull: Rect) -> bool {
679    bbox.x0 > cull.x1 || bbox.x1 < cull.x0 || bbox.y0 > cull.y1 || bbox.y1 < cull.y0
680}
681
682/// Two boxes that bracket a transformed path's exact bounding box: the box of
683/// its **on-curve endpoints**, which is contained in it, and the box of **every
684/// control point**, which contains it. `None` when the path has no segments,
685/// and `None` when any coordinate is not finite.
686///
687/// Both are one pass over the elements with four `min`/`max` per point and no
688/// segment reconstruction, where the exact box — [`path_bbox`] — has to solve
689/// each cubic's extrema.
690///
691/// The bracket is what makes skipping the solve *exact* rather than
692/// approximate: a subset that reaches the clip proves the true box reaches it,
693/// and a superset that misses proves the true box misses. Only a path whose
694/// curve bulges across the clip edge while its endpoints and hull straddle it
695/// differently pays for the solve, and it still gets the same answer.
696///
697/// # Why a non-finite coordinate declines the whole path
698///
699/// `f64::min` and `f64::max` *drop* a NaN operand and return the other, and
700/// the first point of a box is taken rather than folded — so a NaN on a drawn
701/// **endpoint** lands in the inner box unfolded. `outside` compares with `>`
702/// and `<`, and every comparison against a NaN is false, so
703/// `!outside(inner, cull)` answered *keep* for every clip on the page.
704///
705/// The exact box does not agree. kurbo's extrema solve drops the NaN exactly
706/// as `min`/`max` do, so `path_bbox` comes back finite and `outside` answers
707/// it honestly: cull, for a clip the path's finite points miss. The bracket
708/// kept where the exact test culled — a missed cull rather than a wrong one,
709/// so nothing was ever drawn incorrectly, but a disagreement all the same, and
710/// agreeing is the bracket's whole contract.
711///
712/// A NaN *control* point is harmless by the same accident: it reaches only the
713/// outer box, where the fold drops it, and kurbo drops it too. The guard is
714/// written on finiteness rather than on which box a point reaches because that
715/// symmetry is a property of today's kurbo, not a promise.
716///
717/// So any non-finite coordinate declines the bracket and the path takes the
718/// exact spelling — the same policy [`cull_rect`] applies to a clip box it
719/// cannot invert finitely. No corpus document reaches it; a crafted one could.
720fn path_cull_bounds(path: &kurbo::BezPath, matrix: Affine) -> Option<(Rect, Rect)> {
721    // A path that does not open with a move is not a shape kurbo's `segments`
722    // reads the way this bracket assumes, so it takes the exact spelling.
723    if !matches!(path.elements().first(), Some(kurbo::PathEl::MoveTo(_))) {
724        return None;
725    }
726    let mut inner: Option<Rect> = None;
727    let mut outer: Option<Rect> = None;
728    // Set by `add` on the first non-finite transformed point, and checked once
729    // at the end: a path with a NaN in it is rare enough that bailing out of
730    // the loop early would buy nothing, and the flag keeps `add` an
731    // expression.
732    let mut finite = true;
733    let mut add = |bounds: &mut Option<Rect>, p: kurbo::Point| {
734        let p = matrix * p;
735        // After the transform, not before: an affine with a non-finite
736        // coefficient turns finite input non-finite, and it is the point the
737        // box is built from that has to be checked.
738        finite &= p.x.is_finite() && p.y.is_finite();
739        *bounds = Some(match *bounds {
740            Some(r) => Rect::new(r.x0.min(p.x), r.y0.min(p.y), r.x1.max(p.x), r.y1.max(p.y)),
741            None => Rect::new(p.x, p.y, p.x, p.y),
742        });
743    };
744    for el in path.elements() {
745        match *el {
746            // A `MoveTo` goes only into the superset. It *usually* starts a
747            // segment and so is usually in the exact box too — but a `MoveTo`
748            // immediately followed by another one starts no segment at all,
749            // and putting it in the subset would make the subset larger than
750            // the exact box on exactly that path. The superset is unharmed by
751            // a point the exact box does not have.
752            kurbo::PathEl::MoveTo(p) => add(&mut outer, p),
753            kurbo::PathEl::LineTo(p) => {
754                add(&mut inner, p);
755                add(&mut outer, p);
756            }
757            kurbo::PathEl::QuadTo(c, p) => {
758                add(&mut inner, p);
759                add(&mut outer, c);
760                add(&mut outer, p);
761            }
762            kurbo::PathEl::CurveTo(c1, c2, p) => {
763                add(&mut inner, p);
764                add(&mut outer, c1);
765                add(&mut outer, c2);
766                add(&mut outer, p);
767            }
768            kurbo::PathEl::ClosePath => {}
769        }
770    }
771    if !finite {
772        return None;
773    }
774    // A path with no drawn segment at all — one bare `MoveTo`, or a move and a
775    // close — has no endpoint box, and its exact box is `Rect::default()`
776    // rather than the point it names. The two disagree, so it goes the exact
777    // way; there is nothing to save on a path of one element anyway.
778    Some((inner?, outer?))
779}
780
781/// One object's own bounding box in the coordinate space its list is walked
782/// in, or `None` when it has no meaningful extent.
783fn object_bbox(object: &PageObject) -> Option<Rect> {
784    match object {
785        // The transformed box, taken segment by segment rather than by
786        // building a transformed copy of the path.
787        //
788        // `matrix * path` allocates a whole second `BezPath` — every element
789        // of it — and this function runs on *every object of every page* to
790        // decide a cull that then throws the copy away. On `vector_paths_1751`
791        // that is five thousand path allocations per render, none of which
792        // outlives the comparison two lines later. `segments()` walks the
793        // elements by reference, and a segment's own bounding box is exact
794        // (kurbo solves the cubic's extrema rather than hulling its control
795        // points), so the union is the same rectangle the clone produced.
796        PageObject::Path(p) => Some(path_bbox(&p.object.path, p.object.matrix)),
797        PageObject::Image(i) => Some(i.object.matrix.transform_rect_bbox(unit_rect())),
798        PageObject::Shading(s) => Some(s.object.bounds),
799        PageObject::Form(f) => f
800            .object
801            .bbox
802            .map(|b| f.object.matrix.transform_rect_bbox(b)),
803        // A text object's extent needs the font's metrics; the cull is an
804        // optimisation, so declining it is always safe.
805        PageObject::Text(_) => None,
806    }
807}
808
809/// The bounding box of `path` under `matrix`, without building a transformed
810/// copy of it.
811///
812/// Exactly what `(matrix * path).bounding_box()` returns, including the empty
813/// case: kurbo's own `bounding_box` unions its segments' boxes and answers
814/// `Rect::default()` — the degenerate rectangle at the origin — for a path
815/// with no segments, so a bare `MoveTo` culls against the origin here as it
816/// did before. Reproducing that rather than answering `None` keeps the cull
817/// decision identical on every path, which is what makes this a pure
818/// allocation change.
819fn path_bbox(path: &kurbo::BezPath, matrix: Affine) -> Rect {
820    let mut bbox: Option<Rect> = None;
821    for seg in path.segments() {
822        let seg_bb = (matrix * seg).bounding_box();
823        bbox = Some(match bbox {
824            Some(bb) => bb.union(seg_bb),
825            None => seg_bb,
826        });
827    }
828    bbox.unwrap_or_default()
829}
830
831fn unit_rect() -> Rect {
832    Rect::new(0.0, 0.0, 1.0, 1.0)
833}
834
835/// Render one object, through a transparency group when the predicate says
836/// so and directly otherwise.
837///
838/// `children` describes this object's own object list when it is a form, and
839/// is ignored for every other kind.
840#[expect(
841    clippy::too_many_arguments,
842    reason = "the walk threads context, device, backend, caches and a form's \
843              child visibility"
844)]
845pub fn render_object<B: RasterBackend>(
846    ctx: &RenderCtx<'_>,
847    device: &mut B::Device,
848    backend: &B,
849    caches: &mut RenderCaches,
850    object: &PageObject,
851    children: &Visibility,
852    to_device: Affine,
853    device_box: Rect,
854    diags: &mut Diagnostics,
855) {
856    let state = object.state();
857    let clips = crate::walkprofile::phase(crate::walkprofile::Phase::Clip, || {
858        clip::resolve(&state.clip, to_device, &mut caches.glyphs, &ctx.opts)
859    });
860    let pushed = clip::push(device, &clips);
861
862    let initial_alpha = ctx.inherited.fill.map_or(1.0, |_| 1.0);
863    let inputs = GroupInputs::of(object, initial_alpha);
864    if needs_offscreen(inputs) && ctx.depth.may_recurse() {
865        render_grouped(
866            ctx, device, backend, caches, object, children, inputs, to_device, device_box, diags,
867        );
868    } else {
869        render_direct(
870            ctx, device, backend, caches, object, children, to_device, device_box, diags,
871        );
872    }
873
874    clip::pop(device, pushed);
875}
876
877/// Render one object into its own buffer, then composite that buffer back.
878#[expect(
879    clippy::too_many_arguments,
880    reason = "the group path needs every input the direct one had"
881)]
882fn render_grouped<B: RasterBackend>(
883    ctx: &RenderCtx<'_>,
884    device: &mut B::Device,
885    backend: &B,
886    caches: &mut RenderCaches,
887    object: &PageObject,
888    children: &Visibility,
889    inputs: GroupInputs,
890    to_device: Affine,
891    device_box: Rect,
892    diags: &mut Diagnostics,
893) {
894    let state = object.state();
895    // The buffer is sized to the object's own device extent intersected with
896    // the device, so a group off the page costs nothing.
897    let bbox = object_bbox(object)
898        .map_or(device_box, |b| to_device.transform_rect_bbox(b))
899        .intersect(device_box);
900    let rect = outer_rect(bbox).intersect(outer_rect(device_box));
901    let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
902        return;
903    };
904    if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
905        return;
906    }
907
908    let transparency = match object {
909        PageObject::Form(f) => f.object.transparency,
910        _ => ctx.nesting.transparency,
911    };
912    let knockout = match object {
913        PageObject::Form(f) => f.object.transparency.knockout,
914        _ => false,
915    };
916    // Isolated groups (no backdrop copy, no knockout) can composite as a
917    // native layer: clip to the group's bbox, `push_layer`, walk on the
918    // parent. That is the GPU win — no `finish` of the group's content.
919    // Knockout still needs per-object pixmap combine, and non-isolated
920    // still needs `remove_backdrop`. CPU backends leave this off so the
921    // conformance board does not move.
922    if backend.composite_isolated_groups_as_layers() && !needs_backdrop(transparency) && !knockout {
923        render_grouped_as_layer(
924            ctx,
925            device,
926            backend,
927            caches,
928            object,
929            children,
930            inputs,
931            transparency,
932            to_device,
933            device_box,
934            rect,
935            diags,
936        );
937        return;
938    }
939    // Isolated means the group starts transparent; non-isolated means it
940    // starts from a copy of what is already on the page. `[oracle-bug]`:
941    // that copy is kept so it can be **removed again** below, which
942    // `cpdf_renderstatus.cpp` never does — see `Pixmap::remove_backdrop`.
943    let (mut sub, initial_backdrop) = if needs_backdrop(transparency) {
944        let cropped = snapshot_group_backdrop(backend, device, rect);
945        (backend.new_target_with_backdrop(&cropped), Some(cropped))
946    } else {
947        (backend.new_target(w, h, peniko::Color::TRANSPARENT), None)
948    };
949
950    let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
951    let inner_ctx = RenderCtx {
952        nesting: Nesting::in_group(transparency),
953        // The group does *not* inherit the parent's colour: `Initialize(null,
954        // null)` in the C++.
955        inherited: Inherited::NONE,
956        ..ctx.deeper()
957    };
958    let inner_box = Rect::new(0.0, 0.0, f64::from(w), f64::from(h));
959    render_direct(
960        &inner_ctx,
961        &mut sub,
962        backend,
963        caches,
964        object,
965        children,
966        offset * to_device,
967        inner_box,
968        diags,
969    );
970    let mut pixels = backend.finish(sub);
971
972    // `[oracle-bug]`: take the initial backdrop back out before the group
973    // is composited over the very pixels it was copied from, per §11.4.6's
974    // `C = Cn + (Cn - C0) x (a0/agn - a0)`. It must happen *before* the alphas
975    // and the mask, which apply to the group's own contribution.
976    if let Some(backdrop) = &initial_backdrop {
977        pixels.remove_backdrop(backdrop);
978    }
979
980    // The mask first, then the group alpha, then the inherited one — in that
981    // order, and the last only outside an enclosing group.
982    if let Some(mask) = &state.general.soft_mask {
983        let rendered = render_soft_mask(&inner_ctx, backend, caches, mask, rect, to_device, diags);
984        if let Some(m) = rendered {
985            pixels.multiply_alpha_mask(&m);
986        }
987    }
988    // `transparency` is the **form's own**, not the enclosing one
989    // (`cpdf_renderstatus.cpp:646`, `:740-742`): the group alpha is applied
990    // exactly when the object being drawn declares a group, and a form drawn
991    // inside a page that declares one does not thereby inherit the multiply.
992    // Reading the enclosing flag instead drops the group alpha on any form
993    // whose own `/Group` is absent — and applies it to a non-form under a
994    // page that has one, where `inputs.group_alpha` is 1.0 and it is
995    // harmless, which is why this only ever showed up as a missing multiply.
996    GroupFinish::of(inputs, transparency, ctx.nesting.group).apply(&mut pixels);
997
998    // With a premultiplied RGBA target that is both readable and
999    // alpha-capable, PDFium's five-armed compositor collapses to one arm: a
1000    // plain blended blit. The arms it does not take need an opaque target
1001    // that cannot report alpha, which ours never is.
1002    device.push_layer(state.general.blend, 1.0, None);
1003    device.draw_image(
1004        &pixels,
1005        Affine::translate((f64::from(rect.left), f64::from(rect.top))),
1006        ImageQuality::Nearest,
1007        1.0,
1008    );
1009    device.pop();
1010}
1011
1012/// Isolated group as a native layer on the parent device.
1013///
1014/// The group's content is clipped to its bbox so a blend mode that reads
1015/// the backdrop cannot darken pixels the group never painted — the pixmap
1016/// path gets that for free by sizing the buffer to the bbox.
1017#[expect(
1018    clippy::too_many_arguments,
1019    reason = "the layer path takes the same inputs as the pixmap path plus \
1020              the bbox it clips to"
1021)]
1022fn render_grouped_as_layer<B: RasterBackend>(
1023    ctx: &RenderCtx<'_>,
1024    device: &mut B::Device,
1025    backend: &B,
1026    caches: &mut RenderCaches,
1027    object: &PageObject,
1028    children: &Visibility,
1029    inputs: GroupInputs,
1030    transparency: pdfrum_page::Transparency,
1031    to_device: Affine,
1032    device_box: Rect,
1033    rect: IntRect,
1034    diags: &mut Diagnostics,
1035) {
1036    let state = object.state();
1037    let inner_ctx = RenderCtx {
1038        nesting: Nesting::in_group(transparency),
1039        inherited: Inherited::NONE,
1040        ..ctx.deeper()
1041    };
1042    let finish = GroupFinish::of(inputs, transparency, ctx.nesting.group);
1043    let mut layer_alpha = 1.0_f32;
1044    if let Some(a) = finish.group_alpha {
1045        layer_alpha *= a;
1046    }
1047    if let Some(a) = finish.initial_alpha {
1048        layer_alpha *= a;
1049    }
1050
1051    // Soft masks still rasterize to a coverage plane (that *is* pixels),
1052    // then pad to the current target so `push_layer`'s device-sized
1053    // invariant holds. The group's *content* stays on this device.
1054    let mask = state.general.soft_mask.as_ref().and_then(|sm| {
1055        let rendered = render_soft_mask(&inner_ctx, backend, caches, sm, rect, to_device, diags)?;
1056        let (Ok(dw), Ok(dh)) = (
1057            u32::try_from(outer_rect(device_box).width()),
1058            u32::try_from(outer_rect(device_box).height()),
1059        ) else {
1060            return Some(rendered);
1061        };
1062        // The mask lies on the device, whose corner is `device_box`'s: the
1063        // box's own origin for a region render's window, zero otherwise.
1064        let corner = outer_rect(device_box);
1065        Some(rendered.placed_in(dw, dh, rect.left - corner.left, rect.top - corner.top))
1066    });
1067
1068    device.push_clip_rect(rect.to_rect());
1069    device.push_layer(state.general.blend, layer_alpha, mask.as_ref());
1070    render_direct(
1071        &inner_ctx, device, backend, caches, object, children, to_device, device_box, diags,
1072    );
1073    device.pop();
1074    device.pop();
1075}
1076
1077/// The parent pixels under a group's bbox, via [`RasterBackend::snapshot_rect`].
1078fn snapshot_group_backdrop<B: RasterBackend>(
1079    backend: &B,
1080    device: &B::Device,
1081    rect: IntRect,
1082) -> Pixmap {
1083    let origin_x = u32::try_from(rect.left).unwrap_or(0);
1084    let origin_y = u32::try_from(rect.top).unwrap_or(0);
1085    let width = u32::try_from(rect.width()).unwrap_or(0);
1086    let height = u32::try_from(rect.height()).unwrap_or(0);
1087    backend.snapshot_rect(device, origin_x, origin_y, width, height)
1088}
1089
1090/// Render a soft mask's group and read it back as a device-sized coverage
1091/// plane.
1092///
1093/// Four contracts, all pixel-visible:
1094///
1095/// - **The mask renders at exactly the clip rect's device resolution**, on the
1096///   device's own pixel grid, so applying it never resamples.
1097/// - **A luminosity buffer is opaque**, cleared to the `/BC` backdrop
1098///   (default black), so an area the group never paints contributes the
1099///   backdrop's luminosity rather than zero. That is what makes an unpainted
1100///   corner of a `/BC`-white mask fully *reveal* rather than fully hide.
1101/// - **An alpha buffer starts at nothing** and the group renders in alpha
1102///   colour mode, where every drawing operation writes its alpha as gray.
1103/// - **The readback uses the oracle's gray weights, not BT.709.** Both
1104///   rasterizers ship a luminance helper and both use BT.709; neither may be
1105///   used here.
1106fn render_soft_mask<B: RasterBackend>(
1107    ctx: &RenderCtx<'_>,
1108    backend: &B,
1109    caches: &mut RenderCaches,
1110    mask: &pdfrum_page::SoftMask,
1111    rect: IntRect,
1112    to_device: Affine,
1113    diags: &mut Diagnostics,
1114) -> Option<crate::pixmap::AlphaMask> {
1115    if !ctx.depth.may_recurse() {
1116        return None;
1117    }
1118    let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
1119        return None;
1120    };
1121    if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
1122        return None;
1123    }
1124    let mut device = backend.new_target(w, h, crate::softmask::backdrop(mask));
1125    if !mask.objects.is_empty() {
1126        let inner = RenderCtx {
1127            opts: RenderOptions {
1128                color_mode: match mask.kind {
1129                    // An alpha mask's group paints alpha as gray; a
1130                    // luminosity one paints its real colours and the
1131                    // readback greys them.
1132                    pdfrum_page::SoftMaskKind::Alpha => crate::options::ColorMode::Alpha,
1133                    pdfrum_page::SoftMaskKind::Luminosity => crate::options::ColorMode::Normal,
1134                },
1135                ..ctx.opts.clone()
1136            },
1137            // The group renders from a clean slate: `Initialize(null, null)`.
1138            inherited: Inherited::NONE,
1139            // Out of the glyph procedure, but its fonts stay on the ancestry.
1140            type3: Type3Ancestry {
1141                frame: None,
1142                ..ctx.type3
1143            },
1144            nesting: Nesting {
1145                group: GroupNesting::Inside,
1146                ..ctx.nesting
1147            },
1148            ..ctx.deeper()
1149        };
1150        // The mask's own matrix already places it; only the shift into the
1151        // buffer's origin is added, so the mask lands on the device's grid.
1152        let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
1153        render_object_list(
1154            &inner,
1155            &mut device,
1156            backend,
1157            caches,
1158            &mask.objects,
1159            // A soft mask's group is its own object list, not the page's, so
1160            // the page's visibility tree says nothing about it.
1161            &Visibility::all_visible(),
1162            offset * to_device,
1163            Rect::new(0.0, 0.0, f64::from(w), f64::from(h)),
1164            diags,
1165        );
1166    }
1167    let rendered = backend.finish(device);
1168    Some(crate::softmask::readback(mask, &rendered))
1169}
1170
1171/// Dispatch one object to its handler.
1172#[expect(
1173    clippy::too_many_arguments,
1174    reason = "the walk threads context, device, backend and caches"
1175)]
1176fn render_direct<B: RasterBackend>(
1177    ctx: &RenderCtx<'_>,
1178    device: &mut B::Device,
1179    backend: &B,
1180    caches: &mut RenderCaches,
1181    object: &PageObject,
1182    children: &Visibility,
1183    to_device: Affine,
1184    device_box: Rect,
1185    diags: &mut Diagnostics,
1186) {
1187    match object {
1188        PageObject::Path(p) => render_path(
1189            ctx, device, backend, caches, &p.object, &p.state, to_device, device_box, diags,
1190        ),
1191        PageObject::Text(t) => render_text(
1192            ctx, device, backend, caches, &t.object, &t.state, to_device, device_box, diags,
1193        ),
1194        PageObject::Image(i) => render_image(
1195            ctx, device, backend, caches, &i.object, &i.state, to_device, device_box, diags,
1196        ),
1197        PageObject::Shading(s) => render_shading(
1198            ctx, device, backend, &s.object, &s.state, to_device, device_box,
1199        ),
1200        PageObject::Form(f) => {
1201            let inner = RenderCtx {
1202                opts: form_options(&ctx.opts, f.object.live_edit),
1203                inherited: ctx.inherited,
1204                ..ctx.deeper()
1205            };
1206            // `[oracle-bug]`: a knockout group composites each of its own
1207            // objects against the group's **initial** backdrop rather than
1208            // against the accumulated result, so a later object *replaces* an
1209            // earlier one where they overlap instead of blending over it
1210            // (§11.6.6, table 147's `/K`).
1211            if f.object.transparency.knockout {
1212                render_knockout_form(
1213                    &inner,
1214                    device,
1215                    backend,
1216                    caches,
1217                    &f.object.objects,
1218                    children,
1219                    to_device,
1220                    device_box,
1221                    diags,
1222                );
1223                return;
1224            }
1225            // The children's own matrices already carry the form's, because
1226            // `build_page` composes `/Matrix` into the CTM before recursing.
1227            // Composing it again here would apply it twice.
1228            render_object_list(
1229                &inner,
1230                device,
1231                backend,
1232                caches,
1233                &f.object.objects,
1234                children,
1235                to_device,
1236                device_box,
1237                diags,
1238            );
1239        }
1240    }
1241}
1242
1243/// `[oracle-bug]` Render a **knockout** group's objects (§11.6.6, `/K`).
1244///
1245/// PDFium never honours `/K`: the only `/K` read under `core/fpdfapi` is
1246/// CCITT's, and although knockout plumbing exists in `core/fxge/`,
1247/// `RenderDeviceDriverIface::SetGroupKnockout` is an empty body that the AGG
1248/// driver never overrides — so on the oracle's configuration a knockout group
1249/// renders as an ordinary one. pdf.js reads `/I` and `/K`
1250/// (`evaluator.js:523-524`) and implements knockout in earnest
1251/// (`canvas.js:499-534`, `:3310-3318`).
1252///
1253/// The rule §11.6.6 states is that every object in the group composites
1254/// against the group's **initial** backdrop rather than against the
1255/// accumulated result, so a later object *replaces* an earlier one where they
1256/// overlap. That is implemented here by rendering each object into its own
1257/// transparent buffer over that one backdrop and replacing the running result
1258/// wherever the object put coverage down — which is exactly "the last object
1259/// wins per pixel", and degenerates to the ordinary walk when nothing
1260/// overlaps.
1261#[expect(
1262    clippy::too_many_arguments,
1263    reason = "the knockout path needs every input the ordinary walk had"
1264)]
1265fn render_knockout_form<B: RasterBackend>(
1266    ctx: &RenderCtx<'_>,
1267    device: &mut B::Device,
1268    backend: &B,
1269    caches: &mut RenderCaches,
1270    objects: &[PageObject],
1271    children: &Visibility,
1272    to_device: Affine,
1273    device_box: Rect,
1274    diags: &mut Diagnostics,
1275) {
1276    let rect = outer_rect(device_box);
1277    let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
1278        return;
1279    };
1280    if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
1281        // Too large to buffer: fall back to the ordinary walk rather than
1282        // drawing nothing.
1283        render_object_list(
1284            ctx, device, backend, caches, objects, children, to_device, device_box, diags,
1285        );
1286        return;
1287    }
1288
1289    // Each object is composited against this, never against its predecessors.
1290    let mut result: Option<crate::pixmap::Pixmap> = None;
1291    let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
1292    let inner_box = Rect::new(0.0, 0.0, f64::from(w), f64::from(h));
1293
1294    for (index, object) in objects.iter().enumerate() {
1295        if !children.visible(index) {
1296            continue;
1297        }
1298        let mut sub = backend.new_target(w, h, peniko::Color::TRANSPARENT);
1299        render_object(
1300            ctx,
1301            &mut sub,
1302            backend,
1303            caches,
1304            object,
1305            children,
1306            offset * to_device,
1307            inner_box,
1308            diags,
1309        );
1310        let drawn = backend.finish(sub);
1311        match &mut result {
1312            None => result = Some(drawn),
1313            Some(acc) => acc.knockout_over(&drawn),
1314        }
1315    }
1316
1317    if let Some(pixels) = result {
1318        device.draw_image(
1319            &pixels,
1320            Affine::translate((f64::from(rect.left), f64::from(rect.top))),
1321            ImageQuality::Nearest,
1322            1.0,
1323        );
1324    }
1325}
1326
1327/// The options a form `XObject`'s contents render under.
1328///
1329/// One rule: a **live edit's** appearance draws its text with `ClearType`, and
1330/// every other form draws under the options it inherited.
1331///
1332/// That is where the oracle puts the decision too: on a page whose every
1333/// other run is grayscale, the text of the field being edited — and no other
1334/// text — carries subpixel antialiasing. The scope of the override is one
1335/// subtree, the same shape [`RenderOptions::for_type3_char_proc`] already
1336/// uses to force options around a single glyph procedure.
1337///
1338/// A caller's own [`RenderOptions::text_aa_override`] **wins**: it was set
1339/// deliberately for this render, where the flag on the object is a property of
1340/// the document. That ordering also makes the whole thing testable without a
1341/// document — see this module's tests.
1342fn form_options(opts: &RenderOptions, live_edit: bool) -> RenderOptions {
1343    if live_edit && opts.text_aa_override.is_none() {
1344        opts.for_text_run(crate::options::TextAa::LcdSubpixel)
1345    } else {
1346        opts.clone()
1347    }
1348}
1349
1350/// Resolve one object's fill and stroke colours.
1351fn colors(
1352    ctx: &RenderCtx<'_>,
1353    state: &pdfrum_page::GraphicsState,
1354    kind: ObjectKind,
1355) -> (crate::color::Argb, crate::color::Argb) {
1356    crate::walkprofile::phase(crate::walkprofile::Phase::Color, || {
1357        colors_inner(ctx, state, kind)
1358    })
1359}
1360
1361/// [`colors`] without the phase timer around it.
1362fn colors_inner(
1363    ctx: &RenderCtx<'_>,
1364    state: &pdfrum_page::GraphicsState,
1365    kind: ObjectKind,
1366) -> (crate::color::Argb, crate::color::Argb) {
1367    let transfer = state
1368        .general
1369        .transfer
1370        .as_ref()
1371        .map(|t| TransferFunc::new(t));
1372    // A type-3 char proc imposes its caller's colour on every uncoloured
1373    // operation, which is what makes a `d1` glyph take the text object's
1374    // colour rather than black.
1375    let fill = match ctx.type3.frame {
1376        Some(frame)
1377            if frame.colour == crate::ctx::Type3Colour::Imposed
1378                || state.fill.to_rgb().is_none() =>
1379        {
1380            frame.fill
1381        }
1382        _ => resolve_argb(
1383            &state.fill,
1384            state.general.fill_alpha,
1385            transfer.as_ref(),
1386            ctx.inherited.fill,
1387            &ctx.opts,
1388            kind,
1389            false,
1390        ),
1391    };
1392    // `Type3CharMissingStrokeColor`: an uncoloured (`d1`) procedure, or a
1393    // coloured one that never set a stroke, takes the outer text fill for
1394    // strokes too: `t3_fill_color_`, not the stroke the char proc left
1395    // unset. Filling from the frame and stroking from `resolve_argb` is
1396    // what left an uncoloured glyph's outlines black.
1397    let stroke = match ctx.type3.frame {
1398        Some(frame)
1399            if frame.colour == crate::ctx::Type3Colour::Imposed
1400                || state.stroke.to_rgb().is_none() =>
1401        {
1402            frame.fill
1403        }
1404        _ => resolve_argb(
1405            &state.stroke,
1406            state.general.stroke_alpha,
1407            transfer.as_ref(),
1408            ctx.inherited.stroke,
1409            &ctx.opts,
1410            kind,
1411            true,
1412        ),
1413    };
1414    (fill, stroke)
1415}
1416
1417/// Paint one object's geometry with the pattern its fill or stroke colour
1418/// names.
1419///
1420/// The uncoloured colour is resolved here rather than in `pattern.rs` because
1421/// it is a *colour* question — the `scn` operands read through the pattern
1422/// space's base — and because its two fallbacks differ by paint type: a
1423/// coloured tiling pattern whose colour will not resolve falls back to mid
1424/// grey, everything else to white.
1425#[expect(
1426    clippy::too_many_arguments,
1427    reason = "painting a pattern needs the context, device, backend, caches, \
1428              the object's state, which colour names it, its geometry, and \
1429              the page transform"
1430)]
1431fn paint_pattern<B: RasterBackend>(
1432    ctx: &RenderCtx<'_>,
1433    device: &mut B::Device,
1434    backend: &B,
1435    caches: &mut RenderCaches,
1436    state: &pdfrum_page::GraphicsState,
1437    stroking: bool,
1438    geometry: &PatternClip<'_>,
1439    to_device: Affine,
1440    device_box: Rect,
1441    diags: &mut Diagnostics,
1442) {
1443    let color = if stroking { &state.stroke } else { &state.fill };
1444    let Some(value) = color.pattern.as_ref() else {
1445        return;
1446    };
1447    let Some(pattern) = value.loaded.as_ref() else {
1448        return;
1449    };
1450    let alpha = if stroking {
1451        state.general.stroke_alpha
1452    } else {
1453        state.general.fill_alpha
1454    };
1455    let colored_tiling = matches!(&**pattern, pdfrum_page::Pattern::Tiling(t) if t.colored);
1456    let space = color
1457        .space
1458        .as_ref()
1459        .map_or(&pdfrum_page::ColorSpace::DeviceGray, |s| &**s);
1460    let rgb = pdfrum_page::uncolored_pattern_rgb(space, &value.components, colored_tiling);
1461    let [r, g, b] = rgb.to_bytes();
1462    let uncolored = Argb {
1463        a: alpha_byte_truncating(alpha),
1464        r,
1465        g,
1466        b,
1467    };
1468    crate::pattern::draw(
1469        ctx, device, backend, caches, pattern, geometry, to_device, device_box, alpha, uncolored,
1470        diags,
1471    );
1472}
1473
1474#[expect(
1475    clippy::too_many_arguments,
1476    reason = "the pattern arm needs the caches, device box and diagnostics the \
1477              ordinary draw does not"
1478)]
1479fn render_path<B: RasterBackend>(
1480    ctx: &RenderCtx<'_>,
1481    device: &mut B::Device,
1482    backend: &B,
1483    caches: &mut RenderCaches,
1484    object: &pdfrum_page::PathObject,
1485    state: &pdfrum_page::GraphicsState,
1486    to_device: Affine,
1487    device_box: Rect,
1488    diags: &mut Diagnostics,
1489) {
1490    let (fill, stroke) = colors(ctx, state, ObjectKind::Path);
1491    let mut fills = object.fill_rule != pdfrum_page::FillRule::None;
1492    let mut strokes = object.stroke;
1493
1494    // `ProcessPathPattern` runs *before* the ordinary draw and **drains**
1495    // pattern colours out of it: a pattern fill is painted by the pattern
1496    // machinery and the fill type is then set to none, and likewise for a
1497    // stroke, so a path with both is drawn twice and the residual ordinary
1498    // draw does nothing at all.
1499    //
1500    // The draining stands even where the pattern does not resolve. A pattern
1501    // colour has no components, so `to_rgb` reports none and the colour falls
1502    // back to black — which would paint a `scn`-with-no-paint-operator
1503    // rectangle solid black across the whole page where the oracle draws
1504    // nothing.
1505    let matrix = to_device * object.matrix;
1506    if fills && state.fill.is_pattern() {
1507        fills = false;
1508        paint_pattern(
1509            ctx,
1510            device,
1511            backend,
1512            caches,
1513            state,
1514            false,
1515            &PatternClip::Path {
1516                path: &object.path,
1517                to_device: matrix,
1518                stroking: false,
1519                rule: object.fill_rule.into(),
1520                stroke: &state.stroke_params,
1521            },
1522            to_device,
1523            device_box,
1524            diags,
1525        );
1526    }
1527    if strokes && state.stroke.is_pattern() {
1528        strokes = false;
1529        paint_pattern(
1530            ctx,
1531            device,
1532            backend,
1533            caches,
1534            state,
1535            true,
1536            &PatternClip::Path {
1537                path: &object.path,
1538                to_device: matrix,
1539                stroking: true,
1540                rule: object.fill_rule.into(),
1541                stroke: &state.stroke_params,
1542            },
1543            to_device,
1544            device_box,
1545            diags,
1546        );
1547    }
1548    if !fills && !strokes {
1549        return;
1550    }
1551
1552    // Under a forced colour scheme a fill may be converted into a stroke
1553    // wholesale — the only place the two swap roles.
1554    let (fills, strokes) = if matches!(ctx.opts.color_mode, crate::options::ColorMode::Forced(_))
1555        && ctx.opts.convert_fill_to_stroke
1556        && fills
1557    {
1558        (false, true)
1559    } else {
1560        (fills, strokes)
1561    };
1562    let paint = PathPaint {
1563        fill: fills.then_some(fill),
1564        stroke: strokes.then_some(stroke),
1565        rule: object.fill_rule.into(),
1566        text_mode: false,
1567    };
1568    draw_path(
1569        device,
1570        backend,
1571        &object.path,
1572        // `PathObject::matrix` is already the CTM in force when the path was
1573        // emitted — `pdfrum-page` folds every enclosing form's matrix into
1574        // it — so only the page-to-device transform is added here. Composing
1575        // `state.ctm` as well would apply the CTM twice.
1576        to_device * object.matrix,
1577        paint,
1578        &state.stroke_params,
1579        &ctx.opts,
1580        &mut caches.zero_area,
1581    );
1582}
1583
1584/// A run whose fill is a pattern and which is not stroked.
1585///
1586/// No glyph is drawn. The run's **bounding rectangle** becomes a path object
1587/// carrying the text's own colour and general state, and the run itself is
1588/// appended to a copy of the current clip path; that object then goes through
1589/// the ordinary single-object render, where the pattern machinery paints the
1590/// rectangle and the text clip cuts it back to the glyph shapes.
1591///
1592/// Two things follow from it being a *clip* rather than a mask, and both are
1593/// visible: the glyphs are filled winding whatever the run's own mode said,
1594/// and a run whose rectangle is empty paints nothing at all.
1595#[expect(
1596    clippy::too_many_arguments,
1597    reason = "a synthetic path object needs everything the real one does"
1598)]
1599fn render_pattern_text<B: RasterBackend>(
1600    ctx: &RenderCtx<'_>,
1601    device: &mut B::Device,
1602    backend: &B,
1603    caches: &mut RenderCaches,
1604    object: &pdfrum_page::TextObject,
1605    state: &pdfrum_page::GraphicsState,
1606    to_device: Affine,
1607    device_box: Rect,
1608    diags: &mut Diagnostics,
1609) {
1610    let Some(rect) = crate::text::run_rect(object, state) else {
1611        return;
1612    };
1613    // `path.mutable_clip_path().CopyClipPath(last_clip_path_)` then
1614    // `AppendTexts(&pCopy)`: the synthetic object's clip is the current one
1615    // plus this run. The *current* half is already on the device — the caller
1616    // pushed the text object's stack before dispatching here — so only the
1617    // run's own contribution is added, and only it is popped.
1618    let mut clip = pdfrum_page::ClipStack::new();
1619    if clip
1620        .push_text(vec![pdfrum_page::TextClipRun {
1621            object: object.clone(),
1622            char_space: state.text.char_space,
1623            word_space: state.text.word_space,
1624        }])
1625        .is_err()
1626    {
1627        return;
1628    }
1629    // The colour and general state are the text's; the clip is *not* carried
1630    // on the state, because it is pushed on the device around the draw rather
1631    // than resolved again inside it.
1632    let synthetic = pdfrum_page::GraphicsState {
1633        clip: pdfrum_page::ClipStack::new(),
1634        ctm: Affine::IDENTITY,
1635        ..state.clone()
1636    };
1637    // `RenderSingleObject` pushes the object's clip before drawing it, and the
1638    // whole point of this path is the clip it just built — so the clip has to
1639    // be pushed here rather than left to the caller, which has already pushed
1640    // the *text* object's stack and moved on.
1641    let clips = clip::resolve(&clip, to_device, &mut caches.glyphs, &ctx.opts);
1642    let pushed = clip::push(device, &clips);
1643    render_path::<B>(
1644        ctx,
1645        device,
1646        backend,
1647        caches,
1648        &pdfrum_page::PathObject {
1649            path: kurbo::Shape::to_path(&rect, 0.1),
1650            matrix: Affine::IDENTITY,
1651            fill_rule: pdfrum_page::FillRule::Winding,
1652            stroke: false,
1653        },
1654        &synthetic,
1655        to_device,
1656        device_box,
1657        diags,
1658    );
1659    clip::pop(device, pushed);
1660}
1661
1662/// A run whose colour is a pattern and which **is** stroked.
1663///
1664/// Where the unstroked arm replaces the run with its bounding rectangle, this
1665/// one keeps the glyphs: each outline becomes a path object of its own,
1666/// carrying the run's colour and stroke state, and goes through the ordinary
1667/// single-object render — so the pattern machinery paints each glyph's fill,
1668/// its stroke, or both, exactly as it would for a hand-written path.
1669///
1670/// The outline is handed over already in device space with an identity object
1671/// matrix, matching `SetPathMatrix(CFX_Matrix())`: the run's own text matrix
1672/// is folded into the glyph placement rather than left for `ProcessPath` to
1673/// apply. The stroke-CTM split the ordinary stroked-text path performs is
1674/// deliberately absent — `DrawTextPathWithPattern` composes `mtTextMatrix`
1675/// alone and never consults `text_state().GetCTM()`.
1676#[expect(
1677    clippy::too_many_arguments,
1678    reason = "a synthetic path object needs everything the real one does, plus \
1679              the paint kinds the run resolved to"
1680)]
1681fn render_pattern_text_stroked<B: RasterBackend>(
1682    ctx: &RenderCtx<'_>,
1683    device: &mut B::Device,
1684    backend: &B,
1685    caches: &mut RenderCaches,
1686    object: &pdfrum_page::TextObject,
1687    state: &pdfrum_page::GraphicsState,
1688    to_device: Affine,
1689    device_box: Rect,
1690    diags: &mut Diagnostics,
1691    kinds: crate::text::TextPaintKinds,
1692) {
1693    // The bitmap path is a fill-only optimisation and this arm always strokes,
1694    // so the placement is asked for outlines. The buffer is the session's,
1695    // lent out and given back, as in `render_text`.
1696    let mut glyphs = std::mem::take(&mut caches.placed_glyphs);
1697    crate::text::place_glyphs_into(
1698        &mut glyphs,
1699        object,
1700        state,
1701        &mut caches.glyphs,
1702        to_device,
1703        &ctx.opts,
1704        kinds,
1705    );
1706    // `path.set_filltype(fill ? kWinding : kNoFill)`: a stroke-only run
1707    // contributes no fill, and the pattern machinery reads the rule to decide
1708    // whether to paint one.
1709    let fill_rule = if kinds.fill {
1710        pdfrum_page::FillRule::Winding
1711    } else {
1712        pdfrum_page::FillRule::None
1713    };
1714    for glyph in &glyphs {
1715        let path = glyph.device_path();
1716        render_path::<B>(
1717            ctx,
1718            device,
1719            backend,
1720            caches,
1721            &pdfrum_page::PathObject {
1722                path,
1723                matrix: Affine::IDENTITY,
1724                fill_rule,
1725                stroke: true,
1726            },
1727            state,
1728            to_device,
1729            device_box,
1730            diags,
1731        );
1732    }
1733    caches.placed_glyphs = glyphs;
1734}
1735
1736#[expect(
1737    clippy::too_many_arguments,
1738    reason = "the type-3 arm needs the device box and diagnostics the ordinary \
1739              glyph draw does not"
1740)]
1741fn render_text<B: RasterBackend>(
1742    ctx: &RenderCtx<'_>,
1743    device: &mut B::Device,
1744    backend: &B,
1745    caches: &mut RenderCaches,
1746    object: &pdfrum_page::TextObject,
1747    state: &pdfrum_page::GraphicsState,
1748    to_device: Affine,
1749    device_box: Rect,
1750    diags: &mut Diagnostics,
1751) {
1752    let Some((font, _)) = &object.font else {
1753        return;
1754    };
1755    let Some(kinds) = paint_kinds(object.render_mode, has_face(font)) else {
1756        return;
1757    };
1758    if !kinds.fill && !kinds.stroke {
1759        return; // Tr 7 contributes only to the clip, which the stack owns.
1760    }
1761    // A type-3 font has no outlines to fill: each character is a content
1762    // stream, walked with the same machinery a form is.
1763    if font.type3().is_some() {
1764        render_type3_text(
1765            ctx, device, backend, caches, object, state, to_device, device_box, diags,
1766        );
1767        return;
1768    }
1769    // A pattern-coloured glyph run goes to `DrawTextPathWithPattern`, which
1770    // returns before the ordinary draw. The gate is upstream's `bPattern`:
1771    // the colour the run will actually use — the stroke colour for a stroked
1772    // run, the fill colour for a filled one — being a pattern.
1773    //
1774    // The two arms differ in what they hand the pattern machinery. Unstroked,
1775    // no glyph is drawn at all: the run becomes a synthetic path object of its
1776    // own bounding rectangle, with the run appended to a copy of the current
1777    // clip path, so the pattern paints the rectangle and the text clip cuts it
1778    // back to the glyph shapes. Stroked, the glyphs are kept and each outline
1779    // becomes a path object in its own right.
1780    let pattern_run =
1781        (kinds.stroke && state.stroke.is_pattern()) || (kinds.fill && state.fill.is_pattern());
1782    if pattern_run {
1783        if kinds.stroke {
1784            render_pattern_text_stroked(
1785                ctx, device, backend, caches, object, state, to_device, device_box, diags, kinds,
1786            );
1787        } else {
1788            render_pattern_text(
1789                ctx, device, backend, caches, object, state, to_device, device_box, diags,
1790            );
1791        }
1792        return;
1793    }
1794    let (fill, stroke) = colors(ctx, state, ObjectKind::Text);
1795    // `TextObject::matrix` already carries the CTM, so only the
1796    // page-to-device transform is added — composing `state.ctm` again would
1797    // apply it twice.
1798    // The session's placement buffer, lent out for this object and given back
1799    // below. `take` rather than a borrow because the loop needs `caches`
1800    // mutably again — for the bitmap cache and for the zero-area scratch — and
1801    // two mutable borrows of one record do not coexist. The buffer left behind
1802    // is empty, so a re-entrant walk (a form inside this text object, which
1803    // cannot happen, or a future caller for which it could) gets a valid empty
1804    // buffer rather than the one being iterated.
1805    let mut glyphs = std::mem::take(&mut caches.placed_glyphs);
1806    crate::walkprofile::phase(crate::walkprofile::Phase::Glyphs, || {
1807        crate::text::place_glyphs_into(
1808            &mut glyphs,
1809            object,
1810            state,
1811            &mut caches.glyphs,
1812            to_device,
1813            &ctx.opts,
1814            kinds,
1815        );
1816    });
1817    // `DrawTextPath` keeps the post-split text matrix on the path and the
1818    // CTM on the device matrix. `place_glyphs` has already placed into that
1819    // text space when stroking; this is the remaining transform, computed
1820    // once for the run.
1821    let stroke_device = kinds
1822        .stroke
1823        .then(|| stroke_text_matrices(object, state, to_device).1);
1824    for glyph in &glyphs {
1825        // The oracle's small-text path: an alpha bitmap, blitted whole, rather
1826        // than an outline filled where it lands. It is the majority of the
1827        // text in the corpus, and reproducing it is what closes the coverage
1828        // band. When it declines — an unhintable face is fine, but a glyph
1829        // too large or too degenerate to rasterize is not — the oracle skips
1830        // the glyph outright (`if (!glyph.glyph_) continue;`), and so does
1831        // this.
1832        if let Some(placement) = glyph.bitmap
1833            && kinds.fill
1834            && !kinds.stroke
1835        {
1836            draw_glyph_bitmap(
1837                device,
1838                GlyphBlitCaches {
1839                    bitmaps: &mut caches.glyph_bitmaps,
1840                    scratch: &mut caches.glyph_blit,
1841                },
1842                font,
1843                glyph,
1844                placement,
1845                fill,
1846                ctx.opts.effective_text_aa(),
1847            );
1848            continue;
1849        }
1850        let paint = PathPaint {
1851            fill: kinds.fill.then_some(fill),
1852            stroke: kinds.stroke.then_some(stroke),
1853            rule: crate::device::FillRule::Winding,
1854            // The flag that keeps a glyph stem out of the zero-area
1855            // hairline conversion.
1856            text_mode: true,
1857        };
1858        if let Some(device_m) = stroke_device {
1859            // The outline is already in the space line width is measured in;
1860            // composing `device_m * glyph.matrix` back into one transform
1861            // would put font-size/1000 into the stroke scale again.
1862            let path = glyph.device_path();
1863            draw_path(
1864                device,
1865                backend,
1866                &path,
1867                device_m,
1868                paint,
1869                &state.stroke_params,
1870                &ctx.opts,
1871                &mut caches.zero_area,
1872            );
1873        } else {
1874            draw_path(
1875                device,
1876                backend,
1877                &glyph.outline,
1878                glyph.matrix,
1879                paint,
1880                &state.stroke_params,
1881                &ctx.opts,
1882                &mut caches.zero_area,
1883            );
1884        }
1885    }
1886    // Back to the session, with its capacity, for the next text object.
1887    caches.placed_glyphs = glyphs;
1888}
1889
1890/// The two session-owned pieces a glyph blit needs: the bitmaps it may already
1891/// have rasterized, and the buffers it fills for this occurrence.
1892///
1893/// One argument rather than two because they are drawn from the same
1894/// [`crate::ctx::RenderCaches`] and are handed on together.
1895struct GlyphBlitCaches<'a> {
1896    bitmaps: &'a mut crate::glyph::BitmapCache,
1897    scratch: &'a mut crate::ctx::GlyphBlitScratch,
1898}
1899
1900/// Blit one glyph as an alpha bitmap.
1901///
1902/// The bitmap is rasterized about the glyph's **own** origin — the matrix's
1903/// translation is dropped — so that one bitmap serves the glyph wherever it
1904/// lands on the page, which is what makes the cache worth having and what makes
1905/// its key match the oracle's. The placement is then two integers and a phase.
1906///
1907/// The outline it rasterizes is the **hinted** one where the face has hinting
1908/// programs and a table directory, falling back to the unhinted outline
1909/// otherwise, because that is exactly the oracle's `!IsTtOt()` rule plus its
1910/// `FT_LOAD_PEDANTIC` retry.
1911fn draw_glyph_bitmap(
1912    device: &mut dyn RenderDevice,
1913    caches: GlyphBlitCaches<'_>,
1914    font: &pdfrum_font::Font,
1915    glyph: &crate::text::PlacedGlyph,
1916    placement: crate::text::BitmapPlacement,
1917    fill: Argb,
1918    text_aa: crate::options::TextAa,
1919) {
1920    if fill.is_invisible() {
1921        return;
1922    }
1923    // The glyph's shape in device pixels, with its origin at zero. Dropping the
1924    // translation is what the key's four coefficients already assume.
1925    let [a, b, c, d, _, _] = glyph.matrix.as_coeffs();
1926    let shape = Affine::new([a, b, c, d, 0.0, 0.0]);
1927    let key = crate::glyph::BitmapKey::new(glyph.key, shape);
1928
1929    // The bitmap side reads its *own* skew and embolden level, resolved
1930    // against the device matrix (`CFX_Face::RenderGlyph`, `cfx_face.cpp:769`
1931    // and `:806`), where the outline the glyph cache holds was adjusted with
1932    // the path side's (`LoadGlyphPath`, `:869` and `:886`). So the two are
1933    // asked separately and this one is applied here, in device pixels.
1934    //
1935    // `xx` and `xy` are the oracle's 16.16 `matrix.a / 64 * 65536` and
1936    // `matrix.c / 64 * 65536`; ours differ only in that `shape` maps 1000/em
1937    // rather than ems, which the ×1000 undoes.
1938    let ft = |v: f64| {
1939        #[expect(
1940            clippy::cast_possible_truncation,
1941            reason = "the C++ stores these in an FT_Fixed and truncates too; a \
1942                      matrix past i32 belongs to a glyph the coordinate rule \
1943                      has already rejected"
1944        )]
1945        let fixed = (v * 1000.0 / 64.0 * 65536.0) as i32;
1946        fixed
1947    };
1948    // A weight of 1400 or more is past the render table, where the C++ returns
1949    // a negative level and `RenderGlyph` bails out with no bitmap at all.
1950    let Some(synth) = (if glyph.fallback {
1951        font.glyph_fallback()
1952            .and_then(|fb| fb.render_synth(ft(a), ft(c), font.is_vertical()))
1953    } else {
1954        font.render_synth(ft(a), ft(c))
1955    }) else {
1956        return;
1957    };
1958
1959    let GlyphBlitCaches { bitmaps, scratch } = caches;
1960    let Some(lcd) = bitmaps.get_or_insert(key, || {
1961        // A hinted outline is worth up to ten counts a pixel at 6 pt and costs
1962        // a bytecode run, so it is requested only here — on a cache miss — and
1963        // never on the outline path, which the oracle also draws unhinted.
1964        //
1965        // The fallback is the cached outline, which already carries the *path*
1966        // side's adjustments — so it takes only the shear-and-dilate-free
1967        // mapping into device space, and `synth` is applied to the hinted
1968        // outline alone.
1969        let hinted = if glyph.fallback {
1970            font.glyph_fallback()
1971                .and_then(|fb| fb.hinted_path(glyph.key.gid))
1972        } else {
1973            font.hinted_glyph_path(glyph.key.gid)
1974        };
1975        match hinted {
1976            Some(hinted) => crate::glyph::render_lcd(&synth.apply(shape * hinted)),
1977            None => crate::glyph::render_lcd(&(shape * (*glyph.outline).clone())),
1978        }
1979    }) else {
1980        return;
1981    };
1982    // Both spellings place the bitmap the same way, so the corner is computed
1983    // once: the snapped origin plus FreeType's box, both whole numbers, so
1984    // there is nothing to resample either way.
1985    let corner = |left: i32, top: i32| {
1986        (
1987            placement.origin.x + f64::from(left),
1988            placement.origin.y + f64::from(top),
1989        )
1990    };
1991    if matches!(text_aa, crate::options::TextAa::LcdSubpixel) {
1992        // `normalize = false`: the triples stay apart and each becomes one
1993        // destination channel's coverage. It needs its own device call because
1994        // three alphas do not fit in one RGBA pixel.
1995        let bitmap = lcd.to_subpixel(placement.phase);
1996        if bitmap.is_empty() {
1997            return;
1998        }
1999        device.draw_glyph_lcd(&bitmap, corner(bitmap.left, bitmap.top), fill.to_peniko());
2000        return;
2001    }
2002    // Both halves write into buffers the session owns rather than allocating
2003    // per glyph occurrence: the bytes are this glyph's and this colour's and
2004    // are rewritten in full, so only the memory is reused.
2005    let (left, top) = (lcd.left, lcd.top);
2006    if !crate::glyph::recolour_glyph_into(&lcd, placement.phase, fill.to_peniko(), scratch) {
2007        return;
2008    }
2009    // `draw_image` maps the image's own pixel grid, so a plain translation puts
2010    // texel (0, 0) at the bitmap's top-left corner.
2011    device.draw_image(
2012        &scratch.pixels,
2013        Affine::translate(corner(left, top)),
2014        ImageQuality::Nearest,
2015        1.0,
2016    );
2017}
2018
2019/// An uncoloured Type 3 procedure whose one object is a stencil image.
2020///
2021/// `LoadBitmapFromSoleImageOfForm` lifts that image out and blits it as an
2022/// 8bpp mask in the text colour, dropping any clip the procedure accumulated.
2023/// Walking it as a char proc instead keeps the clip and places the image on
2024/// the fractional CTM, which is `bug_1746` and `type3.pdf`.
2025#[must_use]
2026fn sole_stencil(objects: &[PageObject]) -> Option<&pdfrum_page::ImageObject> {
2027    match objects {
2028        [PageObject::Image(content)]
2029            if content.object.is_mask || content.object.image.samples.is_stencil() =>
2030        {
2031            Some(&content.object)
2032        }
2033        _ => None,
2034    }
2035}
2036
2037/// An uncoloured procedure whose one object is a **colour** image.
2038///
2039/// `LoadBitmapFromSoleImageOfForm` realises that bitmap and `SetBitMask`
2040/// tints it with the outer text fill, so luminance is coverage rather than
2041/// a picture. The same snap, nearest-neighbour and dropped-clip as a
2042/// stencil sole-image.
2043#[must_use]
2044fn sole_color_image(objects: &[PageObject]) -> Option<&pdfrum_page::ImageObject> {
2045    match objects {
2046        [PageObject::Image(content)]
2047            if !content.object.is_mask && !content.object.image.samples.is_stencil() =>
2048        {
2049            Some(&content.object)
2050        }
2051        _ => None,
2052    }
2053}
2054
2055/// Draw a Type 3 sole-image the integer-stretch path could not handle.
2056///
2057/// A sheared image, a stencil with empty padding rows, and a colour image
2058/// all miss that path. This is the same snapped, nearest-neighbour image
2059/// draw as before: translation rounds to whole pixels, and a clip the
2060/// procedure accumulated is dropped with the form.
2061#[expect(
2062    clippy::too_many_arguments,
2063    reason = "the sole-image blit needs the same walk arguments as a char proc, \
2064              plus the image, the snapped matrix and the outer fill"
2065)]
2066#[expect(
2067    clippy::many_single_char_names,
2068    reason = "a..f are the affine matrix coefficients, named as in the PDF `cm` operands"
2069)]
2070fn render_type3_sole_stencil<B: RasterBackend>(
2071    ctx: &RenderCtx<'_>,
2072    device: &mut B::Device,
2073    backend: &B,
2074    caches: &mut RenderCaches,
2075    image: &pdfrum_page::ImageObject,
2076    state: &pdfrum_page::GraphicsState,
2077    char_to_device: Affine,
2078    fill: Argb,
2079    device_box: Rect,
2080    diags: &mut Diagnostics,
2081) {
2082    let [a, b, c, d, e, f] = char_to_device.as_coeffs();
2083    let snapped = Affine::new([a, b, c, d, e.round(), f.round()]);
2084    let inner = RenderCtx {
2085        opts: crate::options::RenderOptions {
2086            no_image_smooth: true,
2087            force_halftone: true,
2088            rect_aa: true,
2089            ..ctx.opts.clone()
2090        },
2091        type3: Type3Ancestry {
2092            frame: Some(Type3Frame {
2093                fill,
2094                colour: Type3Colour::Imposed,
2095            }),
2096            ..ctx.type3
2097        },
2098        inherited: Inherited::both(fill),
2099        ..ctx.deeper()
2100    };
2101    render_image::<B>(
2102        &inner, device, backend, caches, image, state, snapped, device_box, diags,
2103    );
2104}
2105
2106/// Draw one type-3 text object, one glyph procedure at a time.
2107///
2108/// Four contracts, each of which changes pixels:
2109///
2110/// - **The colour comes from the *outer* text object**, and a `d1`
2111///   (uncoloured) procedure takes it for every drawing operation inside,
2112///   whatever colours the procedure sets. A `d0` (coloured) one keeps what it
2113///   sets and falls back to the text object's only where it sets none. That
2114///   is the [`Type3Frame`] the child context carries.
2115/// - **`bForceHalftone` and `bRectAA` are forced on**, and the second is
2116///   visible: `bRectAA` disables the axis-aligned rect snapping, so a
2117///   rectangle inside a glyph *is* antialiased where the same rectangle on the
2118///   page would not be.
2119/// - **A translucent procedure goes through its own buffer**, blitted with a
2120///   plain normal blend and no group semantics, so the procedure's own
2121///   overlapping strokes do not accumulate alpha against each other.
2122/// - **The recursion guard is a set of font identities, not a depth.** A font
2123///   may not appear twice anywhere in the ancestry, so a glyph that shows
2124///   text in its own font draws nothing rather than recursing sixty-four
2125///   levels first.
2126#[expect(
2127    clippy::too_many_arguments,
2128    reason = "a glyph procedure needs the context, device, backend, caches, \
2129              the text object, its state, and the page transform"
2130)]
2131fn render_type3_text<B: RasterBackend>(
2132    ctx: &RenderCtx<'_>,
2133    device: &mut B::Device,
2134    backend: &B,
2135    caches: &mut RenderCaches,
2136    object: &pdfrum_page::TextObject,
2137    state: &pdfrum_page::GraphicsState,
2138    to_device: Affine,
2139    device_box: Rect,
2140    diags: &mut Diagnostics,
2141) {
2142    let Some((font, _)) = &object.font else {
2143        return;
2144    };
2145    // The guard is by font identity and is checked before anything is drawn.
2146    if ctx.type3.is_active(font.id()) || !ctx.depth.may_recurse() {
2147        return;
2148    }
2149    // `GetFillArgbForType3` skips the type-3 branch, so this is the outer
2150    // object's own colour even inside a nested procedure.
2151    let fill = resolve_argb(
2152        &state.fill,
2153        state.general.fill_alpha,
2154        state
2155            .general
2156            .transfer
2157            .as_ref()
2158            .map(|t| TransferFunc::new(t))
2159            .as_ref(),
2160        ctx.inherited.fill,
2161        &ctx.opts,
2162        ObjectKind::Text,
2163        false,
2164    );
2165    if fill.is_invisible() {
2166        // A pattern-coloured run resolves to the `0xFFFFFFFF` sentinel, which
2167        // `GetFillArgbForType3` turns into a zero ARGB. There is no
2168        // `DrawTextPathWithPattern` for a type-3 run, so this is where such a
2169        // run stops: the glyphs paint nothing at all.
2170        return;
2171    }
2172    let mut ancestry: Vec<pdfrum_font::FontId> = ctx.type3.fonts.to_vec();
2173    ancestry.push(font.id());
2174
2175    // Sole-image stencils collect into one blit so overlapping glyphs share
2176    // a coverage plane. Anything else flushes first.
2177    let mut pending: Vec<crate::type3::PlacedMask> = Vec::new();
2178    for placed in crate::text::place_type3_chars(object, state, to_device) {
2179        let Some(metrics) = object.type3_metrics.get(&placed.code) else {
2180            continue;
2181        };
2182        if metrics.objects.is_empty() || !is_available_matrix(placed.matrix) {
2183            continue;
2184        }
2185        draw_type3_glyph(
2186            ctx,
2187            device,
2188            backend,
2189            caches,
2190            &mut pending,
2191            metrics,
2192            &placed,
2193            state,
2194            fill,
2195            &ancestry,
2196            device_box,
2197            diags,
2198        );
2199    }
2200    blit_type3_batch(device, &mut pending, fill);
2201}
2202
2203#[expect(
2204    clippy::too_many_arguments,
2205    reason = "one glyph's dispatch needs the walk, the pending stencil batch, \
2206              and the char-proc ancestry"
2207)]
2208fn draw_type3_glyph<B: RasterBackend>(
2209    ctx: &RenderCtx<'_>,
2210    device: &mut B::Device,
2211    backend: &B,
2212    caches: &mut RenderCaches,
2213    pending: &mut Vec<crate::type3::PlacedMask>,
2214    metrics: &pdfrum_page::Type3Metrics,
2215    placed: &crate::text::PlacedType3Char,
2216    state: &pdfrum_page::GraphicsState,
2217    fill: Argb,
2218    ancestry: &[pdfrum_font::FontId],
2219    device_box: Rect,
2220    diags: &mut Diagnostics,
2221) {
2222    if !metrics.colored {
2223        if let Some(image) = sole_stencil(&metrics.objects) {
2224            if let Some(mask) = type3_stencil_glyph(
2225                backend,
2226                caches,
2227                image,
2228                placed.matrix,
2229                ancestry.last().copied().unwrap_or(pdfrum_font::FontId(0)),
2230            ) {
2231                pending.push(mask);
2232                return;
2233            }
2234            blit_type3_batch(device, pending, fill);
2235            render_type3_sole_stencil(
2236                ctx,
2237                device,
2238                backend,
2239                caches,
2240                image,
2241                state,
2242                placed.matrix,
2243                fill,
2244                device_box,
2245                diags,
2246            );
2247            return;
2248        }
2249        if let Some(image) = sole_color_image(&metrics.objects) {
2250            blit_type3_batch(device, pending, fill);
2251            let tinted = pdfrum_page::ImageObject {
2252                image: Arc::new(color_as_fill_mask(&image.image, fill)),
2253                matrix: image.matrix,
2254                is_mask: false,
2255                oc: image.oc.clone(),
2256                source: image.source,
2257            };
2258            render_type3_sole_stencil(
2259                ctx,
2260                device,
2261                backend,
2262                caches,
2263                &tinted,
2264                state,
2265                placed.matrix,
2266                fill,
2267                device_box,
2268                diags,
2269            );
2270            return;
2271        }
2272    }
2273    blit_type3_batch(device, pending, fill);
2274    let inner = RenderCtx {
2275        opts: ctx.opts.for_type3_char_proc(),
2276        type3: Type3Ancestry {
2277            frame: Some(Type3Frame {
2278                fill,
2279                colour: Type3Colour::from_declared(metrics.colored),
2280            }),
2281            fonts: ancestry,
2282        },
2283        inherited: Inherited::both(fill),
2284        ..ctx.deeper()
2285    };
2286    if fill.a == 255 {
2287        render_object_list(
2288            &inner,
2289            device,
2290            backend,
2291            caches,
2292            &metrics.objects,
2293            &Visibility::all_visible(), // the font's objects, not the page's
2294            placed.matrix,
2295            device_box,
2296            diags,
2297        );
2298        return;
2299    }
2300    render_translucent_char_proc(
2301        &inner, device, backend, caches, metrics, placed, fill, device_box, diags,
2302    );
2303}
2304
2305fn type3_stencil_glyph<B: RasterBackend>(
2306    backend: &B,
2307    caches: &mut RenderCaches,
2308    image: &pdfrum_page::ImageObject,
2309    char_to_device: Affine,
2310    font: pdfrum_font::FontId,
2311) -> Option<crate::type3::PlacedMask> {
2312    let blues = caches.type3_blues.for_font_matrix(font, char_to_device);
2313    crate::type3::try_stretch(backend, image, char_to_device, blues)
2314        .and_then(|glyph| glyph.place(char_to_device))
2315}
2316
2317fn blit_type3_batch<D: crate::device::RenderDevice>(
2318    device: &mut D,
2319    pending: &mut Vec<crate::type3::PlacedMask>,
2320    fill: Argb,
2321) {
2322    crate::type3::blit_batch(device, pending, fill);
2323    pending.clear();
2324}
2325
2326/// One type-3 glyph procedure drawn through its own buffer.
2327///
2328/// A translucent glyph cannot paint straight onto the page: its procedure may
2329/// overlap itself, and compositing each stroke at the object's alpha would
2330/// darken the overlaps. So the procedure runs into a buffer sized to the
2331/// glyph's device extent, and the buffer is blitted once.
2332///
2333/// **The alpha rides on the procedure's own fill colour, and the blit is
2334/// opaque.** That is one factor of the object's alpha, applied once — but
2335/// *where* it is applied is the whole of it: painting the procedure opaquely
2336/// and scaling at the blit instead is also one factor, and gives a different
2337/// answer wherever the procedure covers a pixel partially. A half-covered edge
2338/// pixel painted at alpha 128 and blitted opaquely keeps that 128; painted
2339/// opaquely to coverage 128 and blitted at half alpha becomes 64. The oracle
2340/// does the former, and `bug_1746` — whose glyph is a fax-coded image mask
2341/// under `ca 0.5` — is where the two visibly disagree.
2342///
2343/// **The buffer is sized from the objects' own extent, not the declared
2344/// `/FontBBox`.** A `d1` box is its operands scaled by a thousand and put
2345/// through the font matrix; with the conventional thousandth matrix the two
2346/// cancel, and with an identity `/FontMatrix` they do not — `bug_1746`'s box
2347/// lands at x 8050 on a 200-pixel page, so the buffer misses the glyph
2348/// entirely and nothing is drawn at all.
2349#[expect(
2350    clippy::too_many_arguments,
2351    reason = "the same inputs the opaque path takes, plus the placed glyph and \
2352              the colour its alpha is read from"
2353)]
2354fn render_translucent_char_proc<B: RasterBackend>(
2355    ctx: &RenderCtx<'_>,
2356    device: &mut B::Device,
2357    backend: &B,
2358    caches: &mut RenderCaches,
2359    metrics: &pdfrum_page::Type3Metrics,
2360    placed: &crate::text::PlacedType3Char,
2361    fill: Argb,
2362    device_box: Rect,
2363    diags: &mut Diagnostics,
2364) {
2365    let bbox = placed
2366        .matrix
2367        .transform_rect_bbox(metrics.painted)
2368        .intersect(device_box);
2369    let rect = outer_rect(bbox).intersect(outer_rect(device_box));
2370    let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
2371        return;
2372    };
2373    if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
2374        return;
2375    }
2376    let mut sub = backend.new_target(w, h, peniko::Color::TRANSPARENT);
2377    let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
2378    let translucent = Type3Frame {
2379        fill,
2380        colour: Type3Colour::from_declared(metrics.colored),
2381    };
2382    let inner = RenderCtx {
2383        type3: Type3Ancestry {
2384            frame: Some(translucent),
2385            ..ctx.type3
2386        },
2387        inherited: Inherited::both(fill),
2388        ..ctx.clone()
2389    };
2390    render_object_list(
2391        &inner,
2392        &mut sub,
2393        backend,
2394        caches,
2395        &metrics.objects,
2396        &Visibility::all_visible(), // the font's objects, not the page's
2397        offset * placed.matrix,
2398        Rect::new(0.0, 0.0, f64::from(w), f64::from(h)),
2399        diags,
2400    );
2401    let pixels = backend.finish(sub);
2402    device.draw_image(
2403        &pixels,
2404        Affine::translate((f64::from(rect.left), f64::from(rect.top))),
2405        ImageQuality::Nearest,
2406        1.0,
2407    );
2408}
2409
2410/// The resample quality a stencil-as-mask is drawn at.
2411///
2412/// A thin wrapper over [`resample_quality`] that guards the destination
2413/// extent, which the ordinary image path guards separately.
2414#[expect(
2415    clippy::cast_possible_truncation,
2416    reason = "`image_value_fits` rejects a non-finite extent and anything at \
2417              or above MAX_IMAGE_VALUE (2^28), so both rounded values are \
2418              well inside i64"
2419)]
2420fn mask_quality(
2421    image: &pdfrum_page::ImageData,
2422    opts: &RenderOptions,
2423    extent: Rect,
2424    matrix: Affine,
2425) -> ImageQuality {
2426    if !crate::image::image_value_fits(extent.width())
2427        || !crate::image::image_value_fits(extent.height())
2428    {
2429        return ImageQuality::Nearest;
2430    }
2431    resample_quality_at(
2432        image,
2433        opts,
2434        image.width,
2435        image.height,
2436        extent.width().round() as i64,
2437        extent.height().round() as i64,
2438        matrix,
2439    )
2440}
2441
2442/// Paint a stencil whose fill colour is a pattern.
2443///
2444/// The pattern is drawn into its own buffer over the stencil's device extent,
2445/// and the **stencil becomes that buffer's alpha** — so the pattern shows
2446/// through the set bits and nothing shows through the clear ones. Painting a
2447/// pattern colour through the ordinary image path instead paints the
2448/// pattern's *fallback* colour, which for a coloured tiling pattern is mid
2449/// grey.
2450///
2451/// The object's alpha is deliberately **not** applied at the blit: unlike
2452/// `DrawMaskedImage`, the pattern path has already consumed it inside the
2453/// tiling cell's inherited state or the shading's rounded alpha.
2454#[expect(
2455    clippy::too_many_arguments,
2456    reason = "the stencil path needs the context, device, backend, caches, the \
2457              image, its state and the page transform"
2458)]
2459fn render_pattern_stencil<B: RasterBackend>(
2460    ctx: &RenderCtx<'_>,
2461    device: &mut B::Device,
2462    backend: &B,
2463    caches: &mut RenderCaches,
2464    object: &pdfrum_page::ImageObject,
2465    state: &pdfrum_page::GraphicsState,
2466    to_device: Affine,
2467    device_box: Rect,
2468    diags: &mut Diagnostics,
2469) {
2470    if !ctx.depth.may_recurse() {
2471        return;
2472    }
2473    let matrix = to_device * object.matrix;
2474    let bbox = matrix
2475        .transform_rect_bbox(unit_rect())
2476        .intersect(device_box);
2477    let rect = outer_rect(bbox).intersect(outer_rect(device_box));
2478    let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
2479        return;
2480    };
2481    if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
2482        return;
2483    }
2484    let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
2485    let inner_box = Rect::new(0.0, 0.0, f64::from(w), f64::from(h));
2486
2487    // The pattern, over the stencil's whole extent.
2488    let mut pattern_target = backend.new_target(w, h, peniko::Color::TRANSPARENT);
2489    let inner = RenderCtx { ..ctx.deeper() };
2490    paint_pattern(
2491        &inner,
2492        &mut pattern_target,
2493        backend,
2494        caches,
2495        state,
2496        false,
2497        // An image object clips by its transformed bounding box, which here is
2498        // the whole buffer.
2499        &PatternClip::Rect(inner_box),
2500        offset * to_device,
2501        inner_box,
2502        diags,
2503    );
2504    let mut pixels = backend.finish(pattern_target);
2505
2506    // The stencil, rasterized on the same grid so no resampling is needed
2507    // when it becomes the alpha. Its set bits are opaque white; the readback
2508    // takes the alpha channel, which is exactly the coverage.
2509    // The stencil is drawn as a coverage mask for a pattern, so its colour
2510    // is a placeholder and no transfer function applies to it.
2511    let stencil = to_pixmap(&object.image, Argb::opaque(255, 255, 255), None);
2512    if stencil.width() == 0 || stencil.height() == 0 {
2513        return;
2514    }
2515    let placement = offset
2516        * matrix
2517        * Affine::new([
2518            1.0 / f64::from(object.image.width),
2519            0.0,
2520            0.0,
2521            -1.0 / f64::from(object.image.height),
2522            0.0,
2523            1.0,
2524        ]);
2525    // The mask goes through the ordinary image renderer, so it gets the
2526    // ordinary resample selection — which is what puts a soft edge on a
2527    // scaled-up stencil rather than a hard one.
2528    let extent = placement.transform_rect_bbox(Rect::new(
2529        0.0,
2530        0.0,
2531        f64::from(object.image.width),
2532        f64::from(object.image.height),
2533    ));
2534    let quality = mask_quality(&object.image, &ctx.opts, extent, matrix);
2535    let (stencil, placement) =
2536        match crate::stretch::prescale(&stencil, placement, extent.width(), extent.height()) {
2537            Some((reduced, t)) => (reduced, t),
2538            None => (stencil, placement),
2539        };
2540    let mut mask_target = backend.new_target(w, h, peniko::Color::TRANSPARENT);
2541    mask_target.draw_image(
2542        &stencil,
2543        placement,
2544        effective_quality(quality, placement),
2545        1.0,
2546    );
2547    let mask = backend.finish(mask_target).alpha_mask();
2548    pixels.multiply_alpha_mask(&mask);
2549
2550    let blend = overprint_blend(object.image.family, &state.general);
2551    let layered = !matches!(blend, pdfrum_page::BlendMode::Normal);
2552    if layered {
2553        device.push_layer(blend, 1.0, None);
2554    }
2555    device.draw_image(
2556        &pixels,
2557        Affine::translate((f64::from(rect.left), f64::from(rect.top))),
2558        ImageQuality::Nearest,
2559        1.0,
2560    );
2561    if layered {
2562        device.pop();
2563    }
2564}
2565
2566#[expect(
2567    clippy::too_many_arguments,
2568    reason = "the pattern-stencil arm needs the backend, caches, device box \
2569              and diagnostics the ordinary image draw does not"
2570)]
2571fn render_image<B: RasterBackend>(
2572    ctx: &RenderCtx<'_>,
2573    device: &mut B::Device,
2574    backend: &B,
2575    caches: &mut RenderCaches,
2576    object: &pdfrum_page::ImageObject,
2577    state: &pdfrum_page::GraphicsState,
2578    to_device: Affine,
2579    device_box: Rect,
2580    diags: &mut Diagnostics,
2581) {
2582    let matrix = to_device * object.matrix;
2583    if !is_available_matrix(matrix) {
2584        return;
2585    }
2586    // `DrawPatternImage`: a stencil whose fill colour is a pattern paints the
2587    // *pattern* through the stencil, not a colour. The ordinary path would
2588    // paint the pattern's fallback colour — mid grey for a coloured tiling
2589    // one — across every set bit.
2590    if object.is_mask && state.fill.is_pattern() {
2591        render_pattern_stencil(
2592            ctx, device, backend, caches, object, state, to_device, device_box, diags,
2593        );
2594        return;
2595    }
2596    if uses_draw_masked_image(&object.image) {
2597        render_masked_image(ctx, device, backend, object, state, to_device, device_box);
2598        return;
2599    }
2600    let (fill, _) = colors(ctx, state, ObjectKind::Other);
2601    let image = &object.image;
2602    // `StartRenderDIBBase` runs a non-identity `/TR` over the image's own
2603    // samples, not only over the fill colour a stencil takes.
2604    let transfer = state
2605        .general
2606        .transfer
2607        .as_ref()
2608        .map(|t| TransferFunc::new(t));
2609    // The pixmap is the same for every draw of this image at this size, and
2610    // producing it is `O(source pixels)` twice over — so the geometry is
2611    // settled *first* and the pixels asked for last, from the cache. A
2612    // zero-dimension image produces a zero-dimension pixmap, which is what the
2613    // old `pixels.width() == 0` guard was testing; asking the image directly
2614    // says the same thing without decoding 25 million samples to find out.
2615    if image.width == 0 || image.height == 0 {
2616        return;
2617    }
2618    if draw_sheared_image(
2619        ctx,
2620        device,
2621        caches,
2622        object,
2623        state,
2624        matrix,
2625        device_box,
2626        fill,
2627        transfer.as_ref(),
2628    ) {
2629        return;
2630    }
2631    draw_snapped_image::<B>(ctx, device, caches, object, state, matrix);
2632}
2633
2634/// Stretch an axis-aligned image onto its device rect, reduce it if the
2635/// destination is smaller than the source, and blit.
2636#[expect(
2637    clippy::cast_possible_truncation,
2638    reason = "`image_value_fits` has already rejected a non-finite extent and \
2639              anything at or above MAX_IMAGE_VALUE (2^28), so both rounded \
2640              values are well inside i64"
2641)]
2642fn draw_snapped_image<B: RasterBackend>(
2643    ctx: &RenderCtx<'_>,
2644    device: &mut B::Device,
2645    caches: &mut RenderCaches,
2646    object: &pdfrum_page::ImageObject,
2647    state: &pdfrum_page::GraphicsState,
2648    matrix: Affine,
2649) {
2650    let image = &object.image;
2651    let (fill, _) = colors(ctx, state, ObjectKind::Other);
2652    let transfer = state
2653        .general
2654        .transfer
2655        .as_ref()
2656        .map(|t| TransferFunc::new(t));
2657    let transfer = transfer.as_ref();
2658    // The image's unit square maps through the object matrix, so the device
2659    // transform folds in the sample grid's own size and the y flip PDF's
2660    // image space needs.
2661    let placement = matrix
2662        * Affine::new([
2663            1.0 / f64::from(image.width),
2664            0.0,
2665            0.0,
2666            -1.0 / f64::from(image.height),
2667            0.0,
2668            1.0,
2669        ]);
2670    let corners = placement.transform_rect_bbox(Rect::new(
2671        0.0,
2672        0.0,
2673        f64::from(image.width),
2674        f64::from(image.height),
2675    ));
2676    if !crate::image::image_value_fits(corners.width())
2677        || !crate::image::image_value_fits(corners.height())
2678    {
2679        return;
2680    }
2681    let quality = resample_quality_at(
2682        image,
2683        &ctx.opts,
2684        image.width,
2685        image.height,
2686        corners.width().round() as i64,
2687        corners.height().round() as i64,
2688        matrix,
2689    );
2690    // A reduction is low-passed here rather than left to the backend's two-tap
2691    // kernel, which sees at most two of the many source pixels a shrunken
2692    // destination pixel covers. `Reduction` is that decision *and* the
2693    // placement it implies, taken once: where upstream's device-grid snap
2694    // applies the reduced pixmap already is the device pixels and never meets
2695    // the backend's sampler again, and where it does not the ceiled
2696    // footprint and the backend's kernel are what the draw keeps getting.
2697    let reduction = crate::stretch::reduction(
2698        placement,
2699        image.width,
2700        image.height,
2701        corners.width(),
2702        corners.height(),
2703        ctx.type3.frame.is_none(),
2704    );
2705    let (out_w, out_h) = reduction.size(image.width, image.height);
2706    let placement = reduction.transform();
2707    // `to_pixmap` and the reduction are pure in `(image, fill, transfer, size)`
2708    // and were the largest single cost in the corpus, re-run on every render of
2709    // an image that had not changed. Cached
2710    // together, because a caller of one always wants the other: caching the
2711    // unreduced pixmap alone would keep the box filter running per draw *and*
2712    // hold the larger of the two buffers.
2713    let pixels = crate::walkprofile::phase(crate::walkprofile::Phase::Image, || {
2714        let key = crate::imagecache::PixmapRequest::for_image(image, fill, transfer, out_w, out_h);
2715        caches.images.get_or_render(object.source, key, || {
2716            if reduction.filters() {
2717                // The conversion and the reduction are one pull pipeline,
2718                // so neither the full-size RGBA pixmap nor the two
2719                // full-height intermediates are ever built.
2720                crate::stretch::convert_and_reduce(image, fill, transfer, out_w, out_h)
2721            } else {
2722                to_pixmap(image, fill, transfer)
2723            }
2724        })
2725    });
2726    let pixels = &*pixels;
2727    let blend = overprint_blend(image.family, &state.general);
2728    let layered = !matches!(blend, pdfrum_page::BlendMode::Normal);
2729    if layered {
2730        device.push_layer(blend, 1.0, None);
2731    }
2732    // The reduction lands on whole pixels, so when the placement left is a
2733    // whole-pixel translation the reduced pixmap *is* the device pixels and
2734    // the backend has nothing to resample. `Placement` says which case this
2735    // is; `Exact` cannot reach the filtered path because it does not carry a
2736    // transform to filter through.
2737    let placed = image_placement(placement, pixels, ctx.type3.frame.is_some());
2738    device.draw_image(
2739        pixels,
2740        placed.transform_for(pixels.width(), pixels.height()),
2741        placed.quality(effective_quality(quality, placement)),
2742        state.general.fill_alpha,
2743    );
2744    if layered {
2745        device.pop();
2746    }
2747}
2748
2749/// Where one image draw lands, and on which grid.
2750///
2751/// Wraps [`crate::stretch::placement_for`] with the one case its geometry
2752/// cannot see: inside a type-3 char proc the target is a sub-bitmap whose
2753/// origin is the glyph's own outer rect, not the page's. Upstream's snap is
2754/// to the *device* integer grid (`cpdf_imagerenderer.cpp:658-664`), so
2755/// quantising there would land on the sub-target's grid and be requantised
2756/// again when that sub-target is blitted — two roundings where upstream has
2757/// one. `Exact` is unaffected: it is already a whole-pixel translation in
2758/// whichever target it was computed for.
2759fn image_placement(
2760    placement: Affine,
2761    pixels: &crate::pixmap::Pixmap,
2762    in_type3: bool,
2763) -> crate::stretch::Placement {
2764    match crate::stretch::placement_for(placement, pixels.width(), pixels.height()) {
2765        crate::stretch::Placement::Snapped(_) if in_type3 => {
2766            crate::stretch::Placement::Filtered(placement)
2767        }
2768        other => other,
2769    }
2770}
2771
2772/// Draw a sheared image by reverse-mapping dest pixels, then blit 1:1.
2773///
2774/// Returns `false` when the geometry is not drawable this way, so the caller
2775/// can fall through to the quad rasterizer.
2776#[expect(
2777    clippy::too_many_arguments,
2778    reason = "the walk threads context, device, caches, the image and the \
2779              transfer the hypot pixmap is keyed on"
2780)]
2781fn draw_sheared_image<D: RenderDevice>(
2782    ctx: &RenderCtx<'_>,
2783    device: &mut D,
2784    caches: &mut RenderCaches,
2785    object: &pdfrum_page::ImageObject,
2786    state: &pdfrum_page::GraphicsState,
2787    matrix: Affine,
2788    device_box: Rect,
2789    fill: Argb,
2790    transfer: Option<&TransferFunc>,
2791) -> bool {
2792    if !takes_other_transform(matrix) {
2793        return false;
2794    }
2795    let image = &object.image;
2796    let Some((hypot_w, hypot_h)) = crate::shear::hypot_size(matrix) else {
2797        return false;
2798    };
2799    let pass1 = resample_quality(
2800        image,
2801        &ctx.opts,
2802        image.width,
2803        image.height,
2804        i64::from(hypot_w),
2805        i64::from(hypot_h),
2806    );
2807    let key = crate::imagecache::PixmapRequest::for_image(image, fill, transfer, hypot_w, hypot_h);
2808    let src = caches
2809        .images
2810        .get_or_render(object.source, key, || to_pixmap(image, fill, transfer));
2811    let clip = outer_rect(device_box);
2812    let Some(mapped) = crate::shear::map_sheared(&src, matrix, clip, pass1) else {
2813        return false;
2814    };
2815    let blend = overprint_blend(image.family, &state.general);
2816    let layered = !matches!(blend, pdfrum_page::BlendMode::Normal);
2817    if layered {
2818        device.push_layer(blend, 1.0, None);
2819    }
2820    device.draw_image(
2821        &mapped.pixels,
2822        Affine::translate((f64::from(mapped.left), f64::from(mapped.top))),
2823        ImageQuality::Nearest,
2824        state.general.fill_alpha,
2825    );
2826    if layered {
2827        device.pop();
2828    }
2829    true
2830}
2831
2832/// Dest-space reverse-map of a base and its independent mask.
2833#[expect(
2834    clippy::too_many_arguments,
2835    reason = "the mask plane is a separate resolution from the base"
2836)]
2837fn draw_sheared_masked<D: RenderDevice>(
2838    ctx: &RenderCtx<'_>,
2839    device: &mut D,
2840    object: &pdfrum_page::ImageObject,
2841    state: &pdfrum_page::GraphicsState,
2842    matrix: Affine,
2843    device_box: Rect,
2844    mask_dict: &pdfrum_page::ImageData,
2845    mask_plane: &[u8],
2846) -> bool {
2847    if !takes_other_transform(matrix) {
2848        return false;
2849    }
2850    let image = &object.image;
2851    let Some((hypot_w, hypot_h)) = crate::shear::hypot_size(matrix) else {
2852        return false;
2853    };
2854    let (fill, _) = colors(ctx, state, ObjectKind::Other);
2855    let transfer = state
2856        .general
2857        .transfer
2858        .as_ref()
2859        .map(|t| TransferFunc::new(t));
2860    let base = to_pixmap(image, fill, transfer.as_ref());
2861    if base.width() == 0 || base.height() == 0 {
2862        return false;
2863    }
2864    let pass1 = resample_quality(
2865        image,
2866        &ctx.opts,
2867        image.width,
2868        image.height,
2869        i64::from(hypot_w),
2870        i64::from(hypot_h),
2871    );
2872    let mask_q = resample_quality(
2873        mask_dict,
2874        &ctx.opts,
2875        mask_dict.width,
2876        mask_dict.height,
2877        i64::from(hypot_w),
2878        i64::from(hypot_h),
2879    );
2880    let clip = outer_rect(device_box);
2881    let Some(mapped) = crate::shear::map_sheared(&base, matrix, clip, pass1) else {
2882        return false;
2883    };
2884    let Some((mask, mx, my)) = crate::shear::map_sheared_coverage(
2885        mask_plane,
2886        mask_dict.width,
2887        mask_dict.height,
2888        matrix,
2889        clip,
2890        mask_q,
2891    ) else {
2892        return false;
2893    };
2894    if mx != mapped.left
2895        || my != mapped.top
2896        || mask.width() != mapped.pixels.width()
2897        || mask.height() != mapped.pixels.height()
2898    {
2899        return false;
2900    }
2901    let mut pixels = mapped.pixels;
2902    fold_mask_and_blit(
2903        device,
2904        image,
2905        state,
2906        &mut pixels,
2907        &mask,
2908        mapped.left,
2909        mapped.top,
2910    );
2911    true
2912}
2913
2914/// `/Matte`, or a mask on a grid of its own, is a separate dest-space mask
2915/// multiply. A colour-key `/Mask` stays fused.
2916fn uses_draw_masked_image(image: &pdfrum_page::ImageData) -> bool {
2917    image.matte.is_some()
2918        || image
2919            .mask
2920            .as_ref()
2921            .is_some_and(|m| !crate::image::is_coregistered(m, image))
2922}
2923
2924/// Paint an image whose mask has a resolution of its own.
2925///
2926/// A mask is **never resolution-reduced**, so its
2927/// dimensions are its own and generally not the base's: `bug_1396266` puts a
2928/// 64×64 stencil on a 3×3 image, `bug_1236` a 100×100 `/SMask` on a 400×400
2929/// one. PDFium never reconciles the two grids. It renders the base into a
2930/// device-sized buffer, renders the mask into a second buffer over the same
2931/// device rect through the same matrix, and multiplies — so each is resampled
2932/// from its own resolution straight to the device and neither is ever sampled
2933/// at the other's coordinates.
2934///
2935/// Folding the mask into the base's pixels instead reads the wrong sample
2936/// everywhere the sizes differ, and — because
2937/// [`pdfrum_page::ImageMask::alpha_at`] reports out-of-range as opaque —
2938/// leaves the majority of a base larger than its mask completely unmasked.
2939fn render_masked_image<B: RasterBackend>(
2940    ctx: &RenderCtx<'_>,
2941    device: &mut B::Device,
2942    backend: &B,
2943    object: &pdfrum_page::ImageObject,
2944    state: &pdfrum_page::GraphicsState,
2945    to_device: Affine,
2946    device_box: Rect,
2947) {
2948    let image = &object.image;
2949    let Some(mask) = image.mask.as_ref() else {
2950        return;
2951    };
2952    let Some((mask_dict, mask_plane)) = crate::image::separate_mask(mask) else {
2953        return;
2954    };
2955    let matrix = to_device * object.matrix;
2956    if draw_sheared_masked(
2957        ctx,
2958        device,
2959        object,
2960        state,
2961        matrix,
2962        device_box,
2963        &mask_dict,
2964        &mask_plane,
2965    ) {
2966        return;
2967    }
2968    let bbox = matrix
2969        .transform_rect_bbox(unit_rect())
2970        .intersect(device_box);
2971    let rect = outer_rect(bbox).intersect(outer_rect(device_box));
2972    let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
2973        return;
2974    };
2975    if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
2976        return;
2977    }
2978    let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
2979
2980    // The base, over its own extent, with no mask folded in.
2981    let (fill, _) = colors(ctx, state, ObjectKind::Other);
2982    let transfer = state
2983        .general
2984        .transfer
2985        .as_ref()
2986        .map(|t| TransferFunc::new(t));
2987    let base = to_pixmap(image, fill, transfer.as_ref());
2988    if base.width() == 0 || base.height() == 0 {
2989        return;
2990    }
2991    let mut base_target = backend.new_target(w, h, peniko::Color::TRANSPARENT);
2992    let base_placement = offset * matrix * sample_grid(image.width, image.height);
2993    let base_extent = base_placement.transform_rect_bbox(Rect::new(
2994        0.0,
2995        0.0,
2996        f64::from(image.width),
2997        f64::from(image.height),
2998    ));
2999    let base_quality = mask_quality(image, &ctx.opts, base_extent, matrix);
3000    // The base and its mask are reduced independently, each toward its own
3001    // device footprint — which is the same footprint, reached from two
3002    // different resolutions. Neither ever passes through the other's grid.
3003    let (base, base_placement) = match crate::stretch::prescale(
3004        &base,
3005        base_placement,
3006        base_extent.width(),
3007        base_extent.height(),
3008    ) {
3009        Some((reduced, t)) => (reduced, t),
3010        None => (base, base_placement),
3011    };
3012    base_target.draw_image(
3013        &base,
3014        base_placement,
3015        effective_quality(base_quality, base_placement),
3016        1.0,
3017    );
3018    let mut pixels = backend.finish(base_target);
3019
3020    // The mask, over the *same* device rect through the *same* matrix, at its
3021    // own resolution — which is the whole point of the separate pass.
3022    let mask_placement = offset * matrix * sample_grid(mask_dict.width, mask_dict.height);
3023    let mask_extent = mask_placement.transform_rect_bbox(Rect::new(
3024        0.0,
3025        0.0,
3026        f64::from(mask_dict.width),
3027        f64::from(mask_dict.height),
3028    ));
3029    let mask_q = mask_quality(&mask_dict, &ctx.opts, mask_extent, matrix);
3030    // The reduction runs on the coverage plane and the pixmap is built from
3031    // the *reduced* plane — the same bytes prescaling the expanded pixmap
3032    // produces, over a quarter of the memory and, once the reduction fires, a
3033    // small fraction of the buffer. `crate::image::reduced_mask_pixmap` owns
3034    // that equivalence.
3035    let (mask_pixels, mask_placement) = crate::image::reduced_mask_pixmap(
3036        &mask_plane,
3037        mask_dict.width,
3038        mask_dict.height,
3039        mask_placement,
3040        mask_extent.width(),
3041        mask_extent.height(),
3042    );
3043    let mut mask_target = backend.new_target(w, h, peniko::Color::TRANSPARENT);
3044    mask_target.draw_image(
3045        &mask_pixels,
3046        mask_placement,
3047        effective_quality(mask_q, mask_placement),
3048        1.0,
3049    );
3050    let mask = backend.finish(mask_target).alpha_mask();
3051    fold_mask_and_blit(
3052        device,
3053        image,
3054        state,
3055        &mut pixels,
3056        &mask,
3057        rect.left,
3058        rect.top,
3059    );
3060}
3061
3062/// Fold coverage into the dest (matte then replace A, or multiply) and blit.
3063fn fold_mask_and_blit<D: RenderDevice>(
3064    device: &mut D,
3065    image: &pdfrum_page::ImageData,
3066    state: &pdfrum_page::GraphicsState,
3067    pixels: &mut Pixmap,
3068    mask: &crate::pixmap::AlphaMask,
3069    left: i32,
3070    top: i32,
3071) {
3072    if let Some(matte) = image.matte {
3073        // Dest RGB is premultiplied; `/Matte` reads it as a straight sample.
3074        // Recover the colour, un-premultiply against the stretched mask, then
3075        // let the mask replace dest alpha — scaling dest A by the mask would
3076        // count the base's coverage twice.
3077        apply_stretched_mask(pixels, mask, Some(matte.to_bytes()));
3078    } else {
3079        pixels.multiply_alpha_mask(mask);
3080    }
3081    let blend = overprint_blend(image.family, &state.general);
3082    let layered = !matches!(blend, pdfrum_page::BlendMode::Normal);
3083    if layered {
3084        device.push_layer(blend, 1.0, None);
3085    }
3086    device.draw_image(
3087        pixels,
3088        Affine::translate((f64::from(left), f64::from(top))),
3089        ImageQuality::Nearest,
3090        state.general.fill_alpha,
3091    );
3092    if layered {
3093        device.pop();
3094    }
3095}
3096
3097/// The transform from an image's sample grid to its unit square, with the y
3098/// flip PDF image space needs.
3099fn sample_grid(width: u32, height: u32) -> Affine {
3100    Affine::new([
3101        1.0 / f64::from(width),
3102        0.0,
3103        0.0,
3104        -1.0 / f64::from(height),
3105        0.0,
3106        1.0,
3107    ])
3108}
3109
3110fn render_shading<B: RasterBackend>(
3111    ctx: &RenderCtx<'_>,
3112    device: &mut B::Device,
3113    backend: &B,
3114    object: &pdfrum_page::ShadingObject,
3115    state: &pdfrum_page::GraphicsState,
3116    to_device: Affine,
3117    device_box: Rect,
3118) {
3119    let matrix = to_device * object.matrix;
3120    if !is_available_matrix(matrix) {
3121        return;
3122    }
3123    // A bare `sh` paints its whole clip region, with no geometry clip of its
3124    // own; the alpha here is **rounded**, unlike the truncation everywhere
3125    // else colour is resolved.
3126    let alpha = alpha_byte_rounding(state.general.fill_alpha);
3127    // A `sh` with no bounds of its own paints the whole device box.
3128    let mut bbox = if object.bounds.is_zero_area() {
3129        device_box
3130    } else {
3131        to_device.transform_rect_bbox(object.bounds)
3132    };
3133    if let Some(b) = object.shading.bbox {
3134        bbox = bbox.intersect(matrix.transform_rect_bbox(b));
3135    }
3136    let rect = outer_rect(bbox.intersect(device_box));
3137    if !rect.is_valid() {
3138        return;
3139    }
3140    draw_shading_into(ctx, device, backend, &object.shading, rect, matrix, alpha);
3141}
3142
3143/// Rasterize any of the seven shading types into `rect` and blit it.
3144///
3145/// The seventh and sixth need a rasterizer rather than a buffer, so they take
3146/// a scratch device; the other five are pure engine code. Both `sh` and a
3147/// shading *pattern* reach a shading exactly this way, and routing them
3148/// through one function is what keeps a Coons pattern from being the silent
3149/// no-op it was when only `sh` knew about the scratch path.
3150pub(crate) fn draw_shading_into<B: RasterBackend>(
3151    ctx: &RenderCtx<'_>,
3152    device: &mut B::Device,
3153    backend: &B,
3154    shading: &pdfrum_page::Shading,
3155    rect: IntRect,
3156    matrix: Affine,
3157    alpha: u8,
3158) {
3159    let at = Affine::translate((f64::from(rect.left), f64::from(rect.top)));
3160    match shading.kind() {
3161        pdfrum_page::ShadingKind::CoonsMesh | pdfrum_page::ShadingKind::TensorMesh => {
3162            let tensor = shading.kind() == pdfrum_page::ShadingKind::TensorMesh;
3163            let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
3164                return;
3165            };
3166            if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
3167                return;
3168            }
3169            // Every cell goes into the scratch at full opacity, so abutting
3170            // cells overpaint identically on both backends; the shading's
3171            // alpha is applied exactly once, at the blit below.
3172            let mut scratch = backend.new_target(w, h, peniko::Color::TRANSPARENT);
3173            // The mesh half of the shading phase. It was outside every bucket
3174            // until , which is why `shading_axial_radial`'s engine
3175            // residue looked like unattributed `pattern.rs` overhead: the
3176            // document draws 28368 patches through `fill_path` and none of the
3177            // engine-side work that decides them was counted anywhere. Like
3178            // `path prep`, the span covers the device calls it makes, which
3179            // the seam decorator has already charged to RASTER.
3180            crate::walkprofile::phase(crate::walkprofile::Phase::Patches, || {
3181                shading::draw_patches(&mut scratch, shading, rect, matrix, tensor);
3182            });
3183            let pixels = backend.finish(scratch);
3184            device.draw_image(&pixels, at, ImageQuality::Nearest, f32::from(alpha) / 255.0);
3185        }
3186        _ => {
3187            let Some(pixels) =
3188                crate::walkprofile::phase(crate::walkprofile::Phase::Shading, || {
3189                    shading::draw_to_pixmap(shading, rect, matrix, alpha, &ctx.opts)
3190                })
3191            else {
3192                return;
3193            };
3194            device.draw_image(&pixels, at, ImageQuality::Nearest, 1.0);
3195        }
3196    }
3197}
3198
3199#[cfg(test)]
3200mod tests {
3201    use std::collections::BTreeMap;
3202
3203    use kurbo::BezPath;
3204    use pdfrum_page::ContentMarks;
3205    use pdfrum_page::{Content, GraphicsState, PathObject};
3206
3207    use super::*;
3208
3209    fn path_object(path: BezPath, state: GraphicsState) -> PageObject {
3210        PageObject::Path(Box::new(Content {
3211            object: PathObject {
3212                path,
3213                matrix: Affine::IDENTITY,
3214                fill_rule: pdfrum_page::FillRule::Winding,
3215                stroke: false,
3216            },
3217            state,
3218            marks: ContentMarks::new(),
3219            content_stream: Some(0),
3220            dirty: false,
3221            active: true,
3222        }))
3223    }
3224
3225    /// [`path_object`] with the object matrix the cull actually applies.
3226    fn path_object_with_matrix(path: BezPath, matrix: Affine) -> PageObject {
3227        PageObject::Path(Box::new(Content {
3228            object: PathObject {
3229                path,
3230                matrix,
3231                fill_rule: pdfrum_page::FillRule::Winding,
3232                stroke: false,
3233            },
3234            state: GraphicsState::default(),
3235            marks: ContentMarks::new(),
3236            content_stream: Some(0),
3237            dirty: false,
3238            active: true,
3239        }))
3240    }
3241
3242    fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> BezPath {
3243        let mut p = BezPath::new();
3244        p.move_to((x0, y0));
3245        p.line_to((x1, y0));
3246        p.line_to((x1, y1));
3247        p.line_to((x0, y1));
3248        p.close_path();
3249        p
3250    }
3251
3252    #[test]
3253    fn cull_uses_strict_inequalities() {
3254        let cull = Rect::new(0.0, 0.0, 10.0, 10.0);
3255        // Exactly touching the right edge is kept, not dropped.
3256        let touching = path_object(rect(10.0, 0.0, 20.0, 5.0), GraphicsState::default());
3257        assert!(!culled(&touching, cull));
3258        // Strictly beyond it is dropped.
3259        let beyond = path_object(rect(10.1, 0.0, 20.0, 5.0), GraphicsState::default());
3260        assert!(culled(&beyond, cull));
3261    }
3262
3263    /// The two-box bracket must never change a cull decision, only reach it
3264    /// sooner. Its specification is the exact box, so this compares against it
3265    /// over every path shape the cull can meet and every position of the clip
3266    /// relative to that shape — including the one the bracket exists to
3267    /// handle badly, a curve whose bulge crosses an edge its endpoints do not.
3268    #[test]
3269    fn the_cull_bracket_never_changes_the_answer() {
3270        let mut bulging = BezPath::new();
3271        bulging.move_to((0.0, 0.0));
3272        // Control points far above the curve, which peaks at y = 7.5.
3273        bulging.curve_to((0.0, 10.0), (10.0, 10.0), (10.0, 0.0));
3274
3275        let mut two_moves = BezPath::new();
3276        two_moves.move_to((100.0, 100.0)); // starts no segment
3277        two_moves.move_to((0.0, 0.0));
3278        two_moves.line_to((1.0, 1.0));
3279
3280        let mut bare_move = BezPath::new();
3281        bare_move.move_to((50.0, 50.0));
3282
3283        let mut move_close = BezPath::new();
3284        move_close.move_to((50.0, 50.0));
3285        move_close.close_path();
3286
3287        let shapes = [
3288            rect(0.0, 0.0, 10.0, 10.0),
3289            bulging,
3290            two_moves,
3291            bare_move,
3292            move_close,
3293            BezPath::new(),
3294        ];
3295        let matrices = [
3296            Affine::IDENTITY,
3297            Affine::translate((3.0, -4.0)),
3298            Affine::new([2.0, 0.5, -0.5, 2.0, 1.0, 1.0]),
3299        ];
3300        for shape in &shapes {
3301            for matrix in matrices {
3302                let object = path_object_with_matrix(shape.clone(), matrix);
3303                let exact = path_bbox(shape, matrix);
3304                // A grid of clips that slides across the shape a half unit at
3305                // a time, so every edge relation — clear, touching, straddling
3306                // — is exercised on both axes.
3307                let mut x = -12.0;
3308                while x < 14.0 {
3309                    let mut y = -12.0;
3310                    while y < 14.0 {
3311                        let cull = Rect::new(x, y, x + 4.0, y + 4.0);
3312                        assert_eq!(
3313                            culled(&object, cull),
3314                            outside(exact, cull),
3315                            "bracket disagreed at {cull:?} on {shape:?} under {matrix:?}"
3316                        );
3317                        y += 0.5;
3318                    }
3319                    x += 0.5;
3320                }
3321            }
3322        }
3323    }
3324
3325    /// The two inputs the sliding-clip test above cannot reach: a clip of zero
3326    /// size, and a path carrying a coordinate that is not a number.
3327    ///
3328    /// The zero-size clip is the easy half — `outside` is unchanged by it, so
3329    /// a degenerate clip is only a rectangle like any other, and this pins
3330    /// that rather than assuming it.
3331    ///
3332    /// The NaN half is the one the fold has to get right. `f64::min` and
3333    /// `f64::max` drop a NaN operand and return the other, so a fold over a
3334    /// path with a NaN *endpoint* must not put that NaN into the inner box:
3335    /// `outside` compares with `>` and `<`, every comparison against a NaN
3336    /// being false, so `!outside(inner, cull)` would answer **keep** for every
3337    /// clip on the page. The exact box does not agree: kurbo's extrema solve
3338    /// drops the NaN the same way `min`/`max` do, so `path_bbox` is finite and
3339    /// `outside` answers it honestly. A bracket that kept where the exact
3340    /// test culled would change the answer, and that is the one thing this
3341    /// bracket may not do.
3342    ///
3343    /// The direction of a disagreement would be a *missed* cull, not a wrong
3344    /// one. No corpus file reaches it.
3345    #[test]
3346    fn a_degenerate_clip_and_a_non_finite_point_agree_with_the_exact_answer() {
3347        // A NaN on a drawn endpoint, which is what reaches the inner box. The
3348        // other coordinates are finite and far from the clips below, so the
3349        // exact box says "cull" clearly and the disagreement is unambiguous.
3350        let mut nan_endpoint = BezPath::new();
3351        nan_endpoint.move_to((100.0, 100.0));
3352        nan_endpoint.line_to((f64::NAN, 130.0));
3353
3354        // The same, with a finite endpoint after it, so the inner box is a
3355        // real rectangle with a NaN folded into it rather than a NaN point.
3356        let mut nan_midpoint = BezPath::new();
3357        nan_midpoint.move_to((100.0, 100.0));
3358        nan_midpoint.line_to((f64::NAN, 130.0));
3359        nan_midpoint.line_to((105.0, 105.0));
3360
3361        // A NaN control point, which reaches only the outer box. This one the
3362        // two always agreed on — kurbo drops it exactly as the fold does — and
3363        // it is here so a later reader can see that the guard is not what makes
3364        // them agree.
3365        let mut nan_control = BezPath::new();
3366        nan_control.move_to((100.0, 100.0));
3367        nan_control.curve_to((f64::NAN, 110.0), (120.0, 110.0), (130.0, 100.0));
3368
3369        let mut infinite_endpoint = BezPath::new();
3370        infinite_endpoint.move_to((100.0, 100.0));
3371        infinite_endpoint.line_to((f64::INFINITY, 100.0));
3372
3373        let mut ordinary = BezPath::new();
3374        ordinary.move_to((100.0, 100.0));
3375        ordinary.line_to((130.0, 130.0));
3376
3377        let shapes = [
3378            nan_endpoint.clone(),
3379            nan_midpoint,
3380            nan_control.clone(),
3381            infinite_endpoint.clone(),
3382            ordinary.clone(),
3383            rect(0.0, 0.0, 2.0, 2.0),
3384        ];
3385        for shape in &shapes {
3386            for matrix in [
3387                Affine::IDENTITY,
3388                Affine::new([2.0, 0.5, -0.5, 2.0, 1.0, 1.0]),
3389            ] {
3390                let object = path_object_with_matrix(shape.clone(), matrix);
3391                let exact = path_bbox(shape, matrix);
3392                // The clip the review asked for, plus a slide across the
3393                // region where the NaN paths' boxes actually live — the
3394                // disagreement was never at the origin.
3395                let mut x = -20.0;
3396                while x < 140.0 {
3397                    let mut y = 90.0;
3398                    while y < 140.0 {
3399                        for (w, h) in [(0.0, 0.0), (30.0, 30.0)] {
3400                            let cull = Rect::new(x, y, x + w, y + h);
3401                            assert_eq!(
3402                                culled(&object, cull),
3403                                outside(exact, cull),
3404                                "bracket disagreed at {cull:?} on {shape:?} under {matrix:?}"
3405                            );
3406                        }
3407                        y += 2.5;
3408                    }
3409                    x += 2.5;
3410                }
3411                // The zero-size clip the review named, at the exact spot it
3412                // named it.
3413                let degenerate = Rect::new(1.0, 1.0, 1.0, 1.0);
3414                assert_eq!(
3415                    culled(&object, degenerate),
3416                    outside(exact, degenerate),
3417                    "bracket disagreed on the degenerate clip"
3418                );
3419            }
3420        }
3421
3422        // And the mechanism directly, rather than only through the agreement
3423        // above: a non-finite coordinate declines the bracket, so it is the
3424        // exact test that answers.
3425        for declined in [&nan_endpoint, &nan_control, &infinite_endpoint] {
3426            assert!(
3427                path_cull_bounds(declined, Affine::IDENTITY).is_none(),
3428                "a non-finite coordinate must decline the bracket: {declined:?}"
3429            );
3430        }
3431        assert!(
3432            path_cull_bounds(&ordinary, Affine::IDENTITY).is_some(),
3433            "a finite path must still take the bracket"
3434        );
3435        // A finite path under a non-finite transform declines too: the check
3436        // is on the transformed point, which is the one the box is built from.
3437        assert!(
3438            path_cull_bounds(&ordinary, Affine::translate((f64::NAN, 0.0))).is_none(),
3439            "a non-finite matrix must decline the bracket"
3440        );
3441    }
3442
3443    #[test]
3444    fn the_allocation_free_bbox_reproduces_the_transformed_clone_exactly() {
3445        // The specification of `path_bbox` is the expression it replaced:
3446        // `(matrix * path.clone()).bounding_box()`. This is that expression,
3447        // required to agree at every shape the cull can meet — including the
3448        // curves, where a segment's box is solved rather than hulled, and the
3449        // segment-less paths, where kurbo answers the origin rather than
3450        // nothing.
3451        let mut curved = BezPath::new();
3452        curved.move_to((0.0, 0.0));
3453        curved.curve_to((10.0, 40.0), (30.0, -20.0), (40.0, 10.0));
3454        let mut quad = BezPath::new();
3455        quad.move_to((-3.0, 2.0));
3456        quad.quad_to((50.0, 60.0), (7.0, -8.0));
3457        let mut bare_move = BezPath::new();
3458        bare_move.move_to((5.0, 7.0));
3459
3460        let paths = [
3461            rect(1.0, 2.0, 3.0, 4.0),
3462            rect(-9.0, -9.0, -1.0, -1.0),
3463            curved,
3464            quad,
3465            bare_move,
3466            BezPath::new(),
3467        ];
3468        let matrices = [
3469            Affine::IDENTITY,
3470            Affine::scale(2.5),
3471            Affine::translate((13.0, -7.0)),
3472            Affine::rotate(0.7),
3473            Affine::scale_non_uniform(-1.0, 3.0),
3474        ];
3475        for path in &paths {
3476            for m in matrices {
3477                let want = (m * path.clone()).bounding_box();
3478                let got = path_bbox(path, m);
3479                assert_eq!(want, got, "path {path:?} under {m:?}");
3480            }
3481        }
3482    }
3483
3484    #[test]
3485    fn a_text_object_is_never_culled() {
3486        // Its extent needs the font's metrics, so declining the cull is the
3487        // safe answer — it is an optimisation, not a correctness rule.
3488        let obj = PageObject::Text(Box::new(Content {
3489            object: pdfrum_page::TextObject {
3490                segments: Box::new([]),
3491                position: kurbo::Point::ZERO,
3492                matrix: Affine::IDENTITY,
3493                font: None,
3494                font_source: None,
3495                render_mode: pdfrum_page::TextRenderMode::Fill,
3496                type3_metrics: BTreeMap::default(),
3497            },
3498            state: GraphicsState::default(),
3499            marks: ContentMarks::new(),
3500            content_stream: Some(0),
3501            dirty: false,
3502            active: true,
3503        }));
3504        assert!(!culled(&obj, Rect::new(1000.0, 1000.0, 1001.0, 1001.0)));
3505    }
3506
3507    #[test]
3508    fn a_degenerate_matrix_yields_no_cull_rect() {
3509        assert!(cull_rect(Affine::new([0.0; 6]), Rect::new(0.0, 0.0, 10.0, 10.0)).is_none());
3510    }
3511
3512    #[test]
3513    fn target_size_rejects_an_empty_page() {
3514        let mut page = Page::empty();
3515        page.crop_box = Rect::new(0.0, 0.0, 0.0, 0.0);
3516        page.media_box = page.crop_box;
3517        let err = target_size(&page, &RenderOptions::default()).expect_err("empty");
3518        assert!(matches!(err, Error::TargetEmpty { .. }));
3519    }
3520
3521    #[test]
3522    fn target_size_truncates_rather_than_rounding_up() {
3523        // A4 is 595.276 x 841.89 points; `pdfium_test` casts, so the bitmap
3524        // is 595x841. Rounding up costs a one-pixel border, which the
3525        // harness reports as a size mismatch rather than a pixel difference.
3526        let a4 = Rect::new(0.0, 0.0, 595.276, 841.89);
3527        let page = Page {
3528            media_box: a4,
3529            crop_box: a4,
3530            ..Page::empty()
3531        };
3532        assert_eq!(
3533            target_size(&page, &RenderOptions::default()).expect("renderable"),
3534            (595, 841)
3535        );
3536    }
3537
3538    #[test]
3539    fn the_page_matrix_fits_the_page_to_the_truncated_device_box() {
3540        // `GetDisplayMatrixForRect` divides the *integer* device rect by the
3541        // page's float size (`cpdf_page.cpp:216-218`), and
3542        // `CPDFSDK_RenderPageWithContext` passes it the truncated bitmap
3543        // size. So an A4 page 841.89 tall renders into 841 rows: page y = 0
3544        // lands on device row 841 and page y = 841.89 on row 0, exactly.
3545        let a4 = Rect::new(0.0, 0.0, 595.276, 841.89);
3546        let page = Page {
3547            media_box: a4,
3548            crop_box: a4,
3549            ..Page::empty()
3550        };
3551        let m = page_matrix(&page, &RenderOptions::default());
3552        let bottom = m * kurbo::Point::new(0.0, 0.0);
3553        let top = m * kurbo::Point::new(595.276, 841.89);
3554        assert!((bottom.y - 841.0).abs() < 1e-9, "page bottom at {bottom:?}");
3555        assert!((top.y - 0.0).abs() < 1e-9, "page top at {top:?}");
3556        assert!((top.x - 595.0).abs() < 1e-9, "page right at {top:?}");
3557        // Flipping about the float height instead leaves the top of the page
3558        // 0.89 device px out — invisible while glyphs were filled at their
3559        // true position, and a whole row once their origins are snapped.
3560        let midpage = m * kurbo::Point::new(0.0, 420.945);
3561        assert!((midpage.y - 420.5).abs() < 1e-9, "midpage at {midpage:?}");
3562    }
3563
3564    #[test]
3565    fn a_scaled_render_fits_the_page_to_its_own_truncated_box() {
3566        // The caller's transform is consumed in *sizing* the bitmap, exactly
3567        // as `pdfium_test` consumes `--scale`; the display matrix is then
3568        // built onto that size rather than composed on top of it.
3569        let a4 = Rect::new(0.0, 0.0, 595.276, 841.89);
3570        let page = Page {
3571            media_box: a4,
3572            crop_box: a4,
3573            ..Page::empty()
3574        };
3575        let opts = RenderOptions {
3576            transform: Affine::scale(2.0),
3577            ..RenderOptions::default()
3578        };
3579        let (w, h) = target_size(&page, &opts).expect("renderable");
3580        assert_eq!((w, h), (1190, 1683));
3581        let m = page_matrix(&page, &opts);
3582        let corner = m * kurbo::Point::new(595.276, 0.0);
3583        assert!((corner.x - 1190.0).abs() < 1e-9, "{corner:?}");
3584        assert!((corner.y - 1683.0).abs() < 1e-9, "{corner:?}");
3585    }
3586
3587    #[test]
3588    fn target_size_rejects_an_oversized_page() {
3589        let opts = RenderOptions {
3590            transform: Affine::scale(200.0),
3591            ..RenderOptions::default()
3592        };
3593        let err = target_size(&Page::empty(), &opts).expect_err("too large");
3594        assert!(matches!(
3595            err,
3596            Error::TargetTooLarge {
3597                limit: MAX_TARGET_DIMENSION,
3598                ..
3599            }
3600        ));
3601    }
3602
3603    #[test]
3604    fn crop_lifts_a_sub_rectangle() {
3605        let mut src = Pixmap::new(4, 4);
3606        src.set_pixel(2, 3, [1, 2, 3, 255]);
3607        let out = src.cropped(2, 2, 2, 2);
3608        assert_eq!((out.width(), out.height()), (2, 2));
3609        assert_eq!(out.pixel(0, 1), Some([1, 2, 3, 255]));
3610    }
3611
3612    #[test]
3613    fn crop_of_an_out_of_range_rect_is_transparent() {
3614        let src = Pixmap::filled(2, 2, peniko::Color::from_rgba8(9, 9, 9, 255));
3615        let out = src.cropped(10, 10, 2, 2);
3616        assert_eq!(out.pixel(0, 0), Some([0, 0, 0, 0]));
3617    }
3618
3619    #[test]
3620    fn only_a_live_edits_form_turns_clear_type_on() {
3621        use crate::options::TextAa;
3622        let page = RenderOptions::default();
3623        // An ordinary form — the file's own appearance, or any `Do` — inherits
3624        // the page's grayscale unchanged. This is the whole corpus.
3625        assert_eq!(
3626            form_options(&page, false).effective_text_aa(),
3627            TextAa::Grayscale
3628        );
3629        // The live edit's form, and only it, gets ClearType.
3630        assert_eq!(
3631            form_options(&page, true).effective_text_aa(),
3632            TextAa::LcdSubpixel
3633        );
3634        // And the page's own options are untouched either way, so the *next*
3635        // sibling form is grayscale again — the override is scoped to the
3636        // subtree, not latched for the rest of the page.
3637        assert_eq!(page.effective_text_aa(), TextAa::Grayscale);
3638    }
3639
3640    #[test]
3641    fn a_callers_own_text_aa_override_outranks_the_objects_flag() {
3642        use crate::options::TextAa;
3643        // A caller who asked for one thing for this whole render gets it: the
3644        // flag on the object describes the document, the override describes
3645        // the request, and the request is the more specific instruction.
3646        let forced = RenderOptions::default().for_text_run(TextAa::None);
3647        assert_eq!(
3648            form_options(&forced, true).effective_text_aa(),
3649            TextAa::None
3650        );
3651    }
3652
3653    #[test]
3654    fn the_live_edit_fold_changes_nothing_else_about_the_options() {
3655        use crate::options::TextAa;
3656        // The fold must be exactly one field. A form that quietly reset the
3657        // colour mode or the background would be a much larger bug than a
3658        // missing fringe, and would only show up on the one row that reaches
3659        // this branch.
3660        let page = RenderOptions {
3661            color_mode: crate::options::ColorMode::Gray,
3662            no_path_smooth: true,
3663            background: Some(peniko::Color::BLACK),
3664            ..RenderOptions::default()
3665        };
3666        let inner = form_options(&page, true);
3667        assert_eq!(inner.text_aa_override, Some(TextAa::LcdSubpixel));
3668        assert_eq!(inner.color_mode, page.color_mode);
3669        assert!(inner.no_path_smooth);
3670        assert_eq!(inner.background, page.background);
3671        assert_eq!(inner.text_aa, page.text_aa);
3672    }
3673}