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