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