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